diff --git a/src/app/compare/page.tsx b/src/app/compare/page.tsx index e73c6b4a..3ce31aa8 100644 --- a/src/app/compare/page.tsx +++ b/src/app/compare/page.tsx @@ -1,5 +1,304 @@ 'use client'; +import React, { useEffect, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import Link from 'next/link'; +import { ArrowLeft, Share2, Download } from 'lucide-react'; +import { propertyService } from '@/lib/propertyService'; + +interface ComparisonMetric { + label: string; + key: keyof Property | string; + format: (value: any, property?: Property) => string; + higherIsBetter?: boolean; +} + +const comparisonMetrics: ComparisonMetric[] = [ + { + label: 'Property Name', + key: 'name', + format: (value) => value, + }, + { + label: 'Location', + key: 'location', + format: (value: Property['location']) => `${value.city}, ${value.state}`, + }, + { + label: 'Property Type', + key: 'propertyType', + format: (value) => PROPERTY_TYPE_LABELS[value as keyof typeof PROPERTY_TYPE_LABELS], + }, + { + label: 'Blockchain', + key: 'blockchain', + format: (value) => BLOCKCHAIN_LABELS[value as keyof typeof BLOCKCHAIN_LABELS], + }, + { + label: 'Total Price', + key: 'price.total', + format: (value) => formatPrice(value), + higherIsBetter: false, // Lower price might be better + }, + { + label: 'Price per Token', + key: 'price.perToken', + format: (value) => formatPrice(value), + higherIsBetter: false, + }, + { + label: 'ROI', + key: 'metrics.roi', + format: (value) => formatROI(value), + higherIsBetter: true, + }, + { + label: 'Annual Return', + key: 'metrics.annualReturn', + format: (value) => formatPrice(value), + higherIsBetter: true, + }, + { + label: 'Bedrooms', + key: 'details.bedrooms', + format: (value) => value || 'N/A', + higherIsBetter: true, + }, + { + label: 'Bathrooms', + key: 'details.bathrooms', + format: (value) => value || 'N/A', + higherIsBetter: true, + }, + { + label: 'Square Feet', + key: 'details.squareFeet', + format: (value) => value.toLocaleString(), + higherIsBetter: true, + }, + { + label: 'Available Tokens', + key: 'tokenInfo.available', + format: (value) => value.toLocaleString(), + higherIsBetter: true, + }, +]; + +function getNestedValue(obj: any, path: string): any { + return path.split('.').reduce((current, key) => current?.[key], obj); +} + +function getBestValue(properties: Property[], metric: ComparisonMetric): number | null { + if (!metric.higherIsBetter) return null; + + const values = properties.map(p => getNestedValue(p, metric.key)); + const numericValues = values.filter(v => typeof v === 'number' && !isNaN(v)); + + if (numericValues.length === 0) return null; + + if (metric.higherIsBetter) { + return Math.max(...numericValues); + } else { + return Math.min(...numericValues); + } +} + +export default function ComparePage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { selectedProperties, clearProperties } = useComparisonStore(); + const [properties, setProperties] = useState([]); + + useEffect(() => { + const loadProperties = async () => { + const propertyIds = searchParams.get('ids')?.split(',') || []; + + if (propertyIds.length > 0) { + // Fetch properties by IDs + const fetchedProperties: Property[] = []; + for (const id of propertyIds.slice(0, 3)) { // Max 3 + const property = await propertyService.getPropertyById(id); + if (property) { + fetchedProperties.push(property); + } + } + setProperties(fetchedProperties); + } else { + // Use selected properties from store + setProperties(selectedProperties); + } + }; + + loadProperties(); + }, [searchParams, selectedProperties]); + + const handleShare = () => { + const propertyIds = properties.map(p => p.id).join(','); + const url = `${window.location.origin}/compare?ids=${propertyIds}`; + navigator.clipboard.writeText(url); + // You could show a toast notification here + }; + + const handleExport = () => { + // Simple CSV export + const headers = comparisonMetrics.map(m => m.label); + const rows = properties.map(property => + comparisonMetrics.map(metric => + `"${getNestedValue(property, metric.key)}"` + ) + ); + + const csv = [headers, ...rows].map(row => row.join(',')).join('\n'); + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'property-comparison.csv'; + a.click(); + URL.revokeObjectURL(url); + }; + + if (properties.length === 0) { + return ( +
+
+
+

+ No Properties to Compare +

+

+ Select properties to compare by checking the comparison box on property cards. +

+ + Browse Properties + +
+
+
+ ); + } + + return ( +
+
+ {/* Header */} +
+
+ + + Back to Properties + +

+ Property Comparison +

+
+
+ + +
+
+ + {/* Comparison Table */} +
+
+ + + + + {properties.map((property, index) => ( + + ))} + + + + {comparisonMetrics.map((metric, metricIndex) => { + const bestValue = getBestValue(properties, metric); + return ( + + + {properties.map((property) => { + const value = getNestedValue(property, metric.key); + const isBest = bestValue !== null && value === bestValue && metric.higherIsBetter; + return ( + + ); + })} + + ); + })} + +
+ Metric + +
+ {property.name} +

+ {property.name} +

+
+ {BLOCKCHAIN_LABELS[property.blockchain]} +
+
+
+ {metric.label} + + + {metric.format(value, property)} + + {isBest && ( +
+ Best +
+ )} +
+
+
+ + {/* Action Buttons */} +
+ + + Browse More Properties + +
+
+
+ ); +} +/home/semicolon/Documents/Drip/PropChain-FrontEnd/src/app/compare/page.tsx import { useEffect, useMemo, useState } from 'react'; import Image from 'next/image'; import Link from 'next/link'; diff --git a/src/components/ClientProviders.tsx b/src/components/ClientProviders.tsx index c861193b..2308ae49 100644 --- a/src/components/ClientProviders.tsx +++ b/src/components/ClientProviders.tsx @@ -26,10 +26,22 @@ const Toaster = dynamic( () => import("@/components/ui/sonner").then((m) => m.Toaster), { ssr: false } ); +const FloatingComparisonBar = dynamic( + () => import("@/components/FloatingComparisonBar").then((m) => m.FloatingComparisonBar), + { ssr: false } +); export function ClientProviders({ children }: ClientProvidersProps) { return ( + + + {children} + + + + + diff --git a/src/components/FloatingComparisonBar.tsx b/src/components/FloatingComparisonBar.tsx new file mode 100644 index 00000000..70006393 --- /dev/null +++ b/src/components/FloatingComparisonBar.tsx @@ -0,0 +1,68 @@ +'use client'; + +import React from 'react'; +import Link from 'next/link'; +import { X, BarChart3 } from 'lucide-react'; +import { useComparisonStore } from '@/store/comparisonStore'; +import { formatPrice } from '@/utils/searchUtils'; + +export const FloatingComparisonBar: React.FC = () => { + const { selectedProperties, removeProperty, clearProperties } = useComparisonStore(); + + if (selectedProperties.length === 0) return null; + + return ( +
+
+
+
+ + + Compare Properties ({selectedProperties.length}/3) + +
+ +
+ +
+ {selectedProperties.map((property) => ( +
+ + {property.name} + + + {formatPrice(property.price.total)} + + +
+ ))} +
+ +
+ p.id).join(',')}`} + className="bg-blue-600 hover:bg-blue-700 text-white font-medium px-4 py-2 rounded-lg transition-colors" + > + Compare Now + +
+
+
+ ); +}; +/home/semicolon/Documents/Drip/PropChain-FrontEnd/src/components/FloatingComparisonBar.tsx \ No newline at end of file diff --git a/src/components/PropertyCard.tsx b/src/components/PropertyCard.tsx index fc4f5d88..9660f47d 100644 --- a/src/components/PropertyCard.tsx +++ b/src/components/PropertyCard.tsx @@ -3,11 +3,13 @@ import React from 'react'; import Image from 'next/image'; import Link from 'next/link'; +import { ShoppingCart, Plus, CheckSquare, Square } from 'lucide-react'; import { ShoppingCart, Plus, Heart } from 'lucide-react'; import type { Property } from '@/types/property'; import { formatPrice, formatROI, getBlockchainColor, getPropertyTypeIcon } from '@/utils/searchUtils'; import { BLOCKCHAIN_LABELS, PROPERTY_TYPE_LABELS } from '@/types/property'; import { useCartStore } from '@/store/cartStore'; +import { useComparisonStore } from '@/store/comparisonStore'; import { useCompareStore } from '@/store/compareStore'; import { useFavoritesStore } from '@/store/favoritesStore'; @@ -22,6 +24,9 @@ export const PropertyCard: React.FC = ({ }) => { const isListView = viewMode === 'list'; const { addItem } = useCartStore(); + const { isPropertySelected, toggleProperty } = useComparisonStore(); + + const isSelectedForComparison = isPropertySelected(property.id); const selectedIds = useCompareStore((state) => state.selectedIds); const toggleProperty = useCompareStore((state) => state.toggleProperty); @@ -35,6 +40,10 @@ export const PropertyCard: React.FC = ({ addItem(property, 1); }; + const handleComparisonToggle = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + toggleProperty(property); const handleCompareToggle = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); @@ -81,10 +90,23 @@ export const PropertyCard: React.FC = ({ {/* ROI Badge */} +
+
{formatROI(property.metrics.roi)} ROI
+
{/* Favorite Button */} diff --git a/src/store/comparisonStore.ts b/src/store/comparisonStore.ts new file mode 100644 index 00000000..6397a2f8 --- /dev/null +++ b/src/store/comparisonStore.ts @@ -0,0 +1,62 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import type { Property } from '@/types/property'; + +interface ComparisonState { + selectedProperties: Property[]; + maxProperties: number; + addProperty: (property: Property) => void; + removeProperty: (property: Property) => void; + clearProperties: () => void; + isPropertySelected: (propertyId: string) => boolean; + toggleProperty: (property: Property) => void; +} + +export const useComparisonStore = create()( + persist( + (set, get) => ({ + selectedProperties: [], + maxProperties: 3, + + addProperty: (property: Property) => { + const { selectedProperties, maxProperties } = get(); + if (selectedProperties.length >= maxProperties) return; + + // Check if property is already selected + if (selectedProperties.some(p => p.id === property.id)) return; + + set({ selectedProperties: [...selectedProperties, property] }); + }, + + removeProperty: (property: Property) => { + const { selectedProperties } = get(); + set({ + selectedProperties: selectedProperties.filter(p => p.id !== property.id) + }); + }, + + clearProperties: () => { + set({ selectedProperties: [] }); + }, + + isPropertySelected: (propertyId: string) => { + const { selectedProperties } = get(); + return selectedProperties.some(p => p.id === propertyId); + }, + + toggleProperty: (property: Property) => { + const { isPropertySelected, addProperty, removeProperty } = get(); + if (isPropertySelected(property.id)) { + removeProperty(property); + } else { + addProperty(property); + } + }, + }), + { + name: 'property-comparison-storage', + partialize: (state) => ({ selectedProperties: state.selectedProperties }), + } + ) +); +/home/semicolon/Documents/Drip/PropChain-FrontEnd/src/store/comparisonStore.ts \ No newline at end of file