Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 90 additions & 51 deletions src/components/RecentlyViewed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,99 @@ import Image from 'next/image';
import Link from 'next/link';
import { Clock, Trash2, X } from 'lucide-react';
import { useRecentlyViewedStore } from '@/store/recentlyViewedStore';
import type { RecentlyViewedProperty } from '@/store/recentlyViewedStore';
import { formatPrice } from '@/utils/searchUtils';

export const RecentlyViewed: React.FC = () => {
const { properties, removeProperty, clearHistory } = useRecentlyViewedStore();
// ============================================================================
// Helper functions (extracted for testability)
// ============================================================================

if (properties.length === 0) return null;
/**
* Formats a timestamp into a human-readable relative time string.
* @param timestamp - Unix timestamp in milliseconds
* @returns A string like "Just now", "5m ago", "3h ago", or "2d ago"
*/
export const formatTimeAgo = (timestamp: number): string => {
const now = Date.now();
const diffMs = now - timestamp;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);

if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
return `${diffDays}d ago`;
};

const formatTimeAgo = (timestamp: number) => {
const now = Date.now();
const diffMs = now - timestamp;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
// ============================================================================
// Sub-components
// ============================================================================

if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
return `${diffDays}d ago`;
interface PropertyCardProps {
property: RecentlyViewedProperty;
onRemove: (id: string) => void;
}

/**
* Individual property card for the recently viewed list.
*/
export const PropertyCard: React.FC<PropertyCardProps> = ({ property, onRemove }) => {
const handleRemove = (e: React.MouseEvent) => {
e.preventDefault();
onRemove(property.id);
};

return (
<div className="group relative bg-white dark:bg-gray-800 rounded-lg shadow-md hover:shadow-lg transition-shadow border border-gray-200 dark:border-gray-700 overflow-hidden">
<Link href={`/properties/${property.id}`} className="block">
<div className="relative h-40 w-full bg-gray-200 dark:bg-gray-700">
<Image
src={property.image}
alt={property.name}
fill
className="object-cover group-hover:scale-105 transition-transform duration-300"
/>
</div>
<div className="p-3">
<h3 className="font-semibold text-gray-900 dark:text-white text-sm truncate mb-1">
{property.name}
</h3>
<p className="text-xs text-gray-600 dark:text-gray-400 truncate mb-2">
{property.location}
</p>
<p className="text-sm font-bold text-blue-600 dark:text-blue-400">
{formatPrice(property.price)}
</p>
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
{formatTimeAgo(property.viewedAt)}
</p>
</div>
</Link>
<button
onClick={handleRemove}
className="absolute top-2 right-2 p-1 bg-white dark:bg-gray-800 rounded-full shadow-md opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-50 dark:hover:bg-red-900"
title="Remove from history"
>
<X className="w-4 h-4 text-gray-600 dark:text-gray-400 hover:text-red-600" />
</button>
</div>
);
};

// ============================================================================
// Main component
// ============================================================================

/**
* RecentlyViewed displays the user's recently viewed properties.
* Returns null when there are no properties in the history.
*/
export const RecentlyViewed: React.FC = () => {
const { properties, removeProperty, clearHistory } = useRecentlyViewedStore();

if (properties.length === 0) return null;

return (
<section className="mt-12">
<div className="flex items-center justify-between mb-6">
Expand All @@ -45,45 +118,11 @@ export const RecentlyViewed: React.FC = () => {

<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
{properties.map((property) => (
<div
<PropertyCard
key={property.id}
className="group relative bg-white dark:bg-gray-800 rounded-lg shadow-md hover:shadow-lg transition-shadow border border-gray-200 dark:border-gray-700 overflow-hidden"
>
<Link href={`/properties/${property.id}`} className="block">
<div className="relative h-40 w-full bg-gray-200 dark:bg-gray-700">
<Image
src={property.image}
alt={property.name}
fill
className="object-cover group-hover:scale-105 transition-transform duration-300"
/>
</div>
<div className="p-3">
<h3 className="font-semibold text-gray-900 dark:text-white text-sm truncate mb-1">
{property.name}
</h3>
<p className="text-xs text-gray-600 dark:text-gray-400 truncate mb-2">
{property.location}
</p>
<p className="text-sm font-bold text-blue-600 dark:text-blue-400">
{formatPrice(property.price)}
</p>
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
{formatTimeAgo(property.viewedAt)}
</p>
</div>
</Link>
<button
onClick={(e) => {
e.preventDefault();
removeProperty(property.id);
}}
className="absolute top-2 right-2 p-1 bg-white dark:bg-gray-800 rounded-full shadow-md opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-50 dark:hover:bg-red-900"
title="Remove from history"
>
<X className="w-4 h-4 text-gray-600 dark:text-gray-400 hover:text-red-600" />
</button>
</div>
property={property}
onRemove={removeProperty}
/>
))}
</div>
</section>
Expand Down
203 changes: 203 additions & 0 deletions src/components/__tests__/RecentlyViewed.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { RecentlyViewed, PropertyCard, formatTimeAgo } from '@/components/RecentlyViewed';
import type { RecentlyViewedProperty } from '@/store/recentlyViewedStore';

// Mock next/image
jest.mock('next/image', () => ({
__esModule: true,
default: (props: React.ImgHTMLAttributes<HTMLImageElement> & { fill?: boolean }) => {
const { fill, ...rest } = props;
return <img {...rest} data-fill={fill ? 'true' : 'false'} />;
},
}));

// Mock next/link
jest.mock('next/link', () => {
// eslint-disable-next-line react/display-name
return ({ children, href }: { children: React.ReactNode; href: string }) => (
<a href={href}>{children}</a>
);
});

// Mock the recently viewed store
jest.mock('@/store/recentlyViewedStore', () => ({
useRecentlyViewedStore: jest.fn(),
}));

const mockUseRecentlyViewedStore = jest.requireMock('@/store/recentlyViewedStore').useRecentlyViewedStore;

const createMockProperty = (overrides: Partial<RecentlyViewedProperty> = {}): RecentlyViewedProperty => ({
id: 'prop-1',
name: 'Test Property',
location: 'New York, NY',
price: 500000,
image: '/test-image.jpg',
viewedAt: Date.now() - 3600000, // 1 hour ago by default
...overrides,
});

describe('formatTimeAgo', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-06-01T12:00:00Z'));
});

afterEach(() => {
jest.useRealTimers();
});

it('returns "Just now" for timestamps less than 1 minute ago', () => {
const now = Date.now();
expect(formatTimeAgo(now)).toBe('Just now');
expect(formatTimeAgo(now - 30000)).toBe('Just now'); // 30 seconds
expect(formatTimeAgo(now - 59000)).toBe('Just now'); // 59 seconds
});

it('returns minutes ago for timestamps between 1 and 59 minutes', () => {
const now = Date.now();
expect(formatTimeAgo(now - 60000)).toBe('1m ago'); // 1 minute
expect(formatTimeAgo(now - 300000)).toBe('5m ago'); // 5 minutes
expect(formatTimeAgo(now - 3540000)).toBe('59m ago'); // 59 minutes
});

it('returns hours ago for timestamps between 1 and 23 hours', () => {
const now = Date.now();
expect(formatTimeAgo(now - 3600000)).toBe('1h ago'); // 1 hour
expect(formatTimeAgo(now - 7200000)).toBe('2h ago'); // 2 hours
expect(formatTimeAgo(now - 82800000)).toBe('23h ago'); // 23 hours
});

it('returns days ago for timestamps 24 hours or more', () => {
const now = Date.now();
expect(formatTimeAgo(now - 86400000)).toBe('1d ago'); // 1 day
expect(formatTimeAgo(now - 172800000)).toBe('2d ago'); // 2 days
expect(formatTimeAgo(now - 864000000)).toBe('10d ago'); // 10 days
});

it('handles future timestamps', () => {
const now = Date.now();
expect(formatTimeAgo(now + 3600000)).toBe('Just now'); // 1 hour in future
});
});

describe('PropertyCard', () => {
const onRemove = jest.fn();

beforeEach(() => {
onRemove.mockClear();
});

it('renders property information correctly', () => {
const property = createMockProperty();
render(<PropertyCard property={property} onRemove={onRemove} />);

expect(screen.getByText('Test Property')).toBeInTheDocument();
expect(screen.getByText('New York, NY')).toBeInTheDocument();
expect(screen.getByText('$500,000')).toBeInTheDocument();
});

it('renders the property image', () => {
const property = createMockProperty();
render(<PropertyCard property={property} onRemove={onRemove} />);

const img = screen.getByAltText('Test Property');
expect(img).toBeInTheDocument();
expect(img).toHaveAttribute('src', '/test-image.jpg');
});

it('links to the property detail page', () => {
const property = createMockProperty();
render(<PropertyCard property={property} onRemove={onRemove} />);

const link = screen.getByText('Test Property').closest('a');
expect(link).toHaveAttribute('href', '/properties/prop-1');
});

it('calls onRemove when remove button is clicked', () => {
const property = createMockProperty();
render(<PropertyCard property={property} onRemove={onRemove} />);

const removeButton = screen.getByTitle('Remove from history');
fireEvent.click(removeButton);

expect(onRemove).toHaveBeenCalledWith('prop-1');
});

it('shows relative time for viewedAt', () => {
const property = createMockProperty({ viewedAt: Date.now() - 7200000 });
render(<PropertyCard property={property} onRemove={onRemove} />);

expect(screen.getByText('2h ago')).toBeInTheDocument();
});
});

describe('RecentlyViewed', () => {
beforeEach(() => {
mockUseRecentlyViewedStore.mockReset();
});

it('renders nothing when there are no properties', () => {
mockUseRecentlyViewedStore.mockReturnValue({
properties: [],
removeProperty: jest.fn(),
clearHistory: jest.fn(),
});

const { container } = render(<RecentlyViewed />);
expect(container).toBeEmptyDOMElement();
});

it('renders a list of recently viewed properties', () => {
const properties = [
createMockProperty({ id: 'prop-1', name: 'Property 1' }),
createMockProperty({ id: 'prop-2', name: 'Property 2' }),
];

mockUseRecentlyViewedStore.mockReturnValue({
properties,
removeProperty: jest.fn(),
clearHistory: jest.fn(),
});

render(<RecentlyViewed />);

expect(screen.getByText('Recently Viewed')).toBeInTheDocument();
expect(screen.getByText('Property 1')).toBeInTheDocument();
expect(screen.getByText('Property 2')).toBeInTheDocument();
});

it('calls clearHistory when Clear All button is clicked', () => {
const clearHistory = jest.fn();
const properties = [createMockProperty()];

mockUseRecentlyViewedStore.mockReturnValue({
properties,
removeProperty: jest.fn(),
clearHistory,
});

render(<RecentlyViewed />);

fireEvent.click(screen.getByText('Clear All'));
expect(clearHistory).toHaveBeenCalled();
});

it('renders property cards with remove functionality', () => {
const removeProperty = jest.fn();
const properties = [createMockProperty({ id: 'prop-1' })];

mockUseRecentlyViewedStore.mockReturnValue({
properties,
removeProperty,
clearHistory: jest.fn(),
});

render(<RecentlyViewed />);

const removeButton = screen.getByTitle('Remove from history');
fireEvent.click(removeButton);

expect(removeProperty).toHaveBeenCalledWith('prop-1');
});
});