Skip to content

Commit e7f8451

Browse files
Add new listings feature with source filtering and tabbed model views
- Add NewListings component displaying recently added vehicles - Table format with Make, Model columns - Source filter dropdown with URL persistence (?source=carmax) - Positioned below OverviewChart on main page - Create ModelListingsView with tabs for model detail pages - "All Listings" and "New Listings" tabs - Tab state persists via URL param (?tab=new) - Shows listing counts on each tab - Refactor ListingsTable to be reusable - Accept listings array prop instead of computing internally - Optional showModel prop to display Make/Model columns - Configurable title and emptyMessage - Update dataLoader.js - findNewListings() now includes source information - Compares most recent vs previous scrape dates All filters and views support URL state persistence, browser back/forward navigation, and page refresh. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 7f752fb commit e7f8451

File tree

7 files changed

+341
-29
lines changed

7 files changed

+341
-29
lines changed

src/App.jsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import React, { useState, useEffect } from 'react';
22
import { loadAllData, getModelKey } from './services/dataLoader';
33
import OverviewChart from './components/OverviewChart';
44
import DetailChart from './components/DetailChart';
5-
import ListingsTable from './components/ListingsTable';
5+
import ModelListingsView from './components/ModelListingsView';
6+
import NewListings from './components/NewListings';
67

78
function App() {
89
const [data, setData] = useState([]);
@@ -73,7 +74,10 @@ function App() {
7374
</header>
7475
<main className="container">
7576
{!selectedModel ? (
76-
<OverviewChart data={data} onModelSelect={handleModelSelect} />
77+
<>
78+
<OverviewChart data={data} onModelSelect={handleModelSelect} />
79+
<NewListings data={data} />
80+
</>
7781
) : (
7882
<>
7983
<div className="breadcrumb">
@@ -82,7 +86,7 @@ function App() {
8286
</a> / {selectedModel}
8387
</div>
8488
<DetailChart data={data} model={selectedModel} />
85-
<ListingsTable data={data} model={selectedModel} />
89+
<ModelListingsView data={data} model={selectedModel} />
8690
</>
8791
)}
8892
</main>

src/components/ListingsTable.jsx

Lines changed: 15 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,11 @@
11
import React from 'react';
22
import './ListingsTable.css';
33

4-
export default function ListingsTable({ data, model }) {
5-
if (!data || data.length === 0 || !model) {
4+
export default function ListingsTable({ listings, title = "Current Listings", emptyMessage = "No listings found", showModel = false }) {
5+
if (!listings || listings.length === 0) {
66
return null;
77
}
88

9-
// Get latest data only
10-
const latestDate = data
11-
.map(d => d.scraped_at)
12-
.sort()
13-
.reverse()[0]
14-
?.split('T')[0];
15-
16-
const latestData = data.filter(
17-
d => d.scraped_at.startsWith(latestDate)
18-
);
19-
20-
const listings = [];
21-
latestData.forEach(sourceData => {
22-
sourceData.listings.forEach(listing => {
23-
if (`${listing.make} ${listing.model}` === model) {
24-
listings.push({ ...listing, source: sourceData.source });
25-
}
26-
});
27-
});
28-
29-
// Sort by price
30-
listings.sort((a, b) => a.price - b.price);
31-
329
const sourceColors = {
3310
'mock-source': 'source-mock',
3411
'carmax': 'source-carmax',
@@ -38,11 +15,17 @@ export default function ListingsTable({ data, model }) {
3815

3916
return (
4017
<div className="listings-table">
41-
<h2>Current Listings</h2>
18+
<h2>{title}</h2>
4219
<table>
4320
<thead>
4421
<tr>
4522
<th>Source</th>
23+
{showModel && (
24+
<>
25+
<th>Make</th>
26+
<th>Model</th>
27+
</>
28+
)}
4629
<th>Year</th>
4730
<th>Trim</th>
4831
<th>Price</th>
@@ -59,6 +42,12 @@ export default function ListingsTable({ data, model }) {
5942
{listing.source}
6043
</span>
6144
</td>
45+
{showModel && (
46+
<>
47+
<td>{listing.make}</td>
48+
<td>{listing.model}</td>
49+
</>
50+
)}
6251
<td>{listing.year}</td>
6352
<td>{listing.trim}</td>
6453
<td className="price">${listing.price.toLocaleString()}</td>
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
.model-listings-view {
2+
margin-top: 2rem;
3+
}
4+
5+
.tabs {
6+
display: flex;
7+
gap: 0.5rem;
8+
margin-bottom: 1rem;
9+
border-bottom: 2px solid #e0e0e0;
10+
}
11+
12+
.tab {
13+
background: none;
14+
border: none;
15+
padding: 0.75rem 1.5rem;
16+
font-size: 1rem;
17+
font-weight: 500;
18+
color: #666;
19+
cursor: pointer;
20+
border-bottom: 2px solid transparent;
21+
margin-bottom: -2px;
22+
transition: all 0.2s ease;
23+
}
24+
25+
.tab:hover {
26+
color: #667eea;
27+
background: #f8f9fa;
28+
}
29+
30+
.tab.active {
31+
color: #667eea;
32+
border-bottom-color: #667eea;
33+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import React, { useState, useEffect } from 'react';
2+
import ListingsTable from './ListingsTable';
3+
import { findNewListings } from '../services/dataLoader';
4+
import './ModelListingsView.css';
5+
6+
export default function ModelListingsView({ data, model }) {
7+
const [activeTab, setActiveTab] = useState('all');
8+
9+
// Load tab from URL on mount
10+
useEffect(() => {
11+
const url = new URL(window.location);
12+
const tab = url.searchParams.get('tab');
13+
if (tab === 'new' || tab === 'all') {
14+
setActiveTab(tab);
15+
}
16+
}, []);
17+
18+
// Handle browser back/forward
19+
useEffect(() => {
20+
const handlePopState = () => {
21+
const url = new URL(window.location);
22+
const tab = url.searchParams.get('tab') || 'all';
23+
setActiveTab(tab);
24+
};
25+
26+
window.addEventListener('popstate', handlePopState);
27+
return () => window.removeEventListener('popstate', handlePopState);
28+
}, []);
29+
30+
if (!data || data.length === 0 || !model) {
31+
return null;
32+
}
33+
34+
const handleTabChange = (tab) => {
35+
setActiveTab(tab);
36+
const url = new URL(window.location);
37+
if (tab === 'all') {
38+
url.searchParams.delete('tab');
39+
} else {
40+
url.searchParams.set('tab', tab);
41+
}
42+
window.history.pushState({}, '', url);
43+
};
44+
45+
// Get latest data only for "all" tab
46+
const latestDate = data
47+
.map(d => d.scraped_at)
48+
.sort()
49+
.reverse()[0]
50+
?.split('T')[0];
51+
52+
const latestData = data.filter(
53+
d => d.scraped_at.startsWith(latestDate)
54+
);
55+
56+
const allListings = [];
57+
latestData.forEach(sourceData => {
58+
sourceData.listings.forEach(listing => {
59+
if (`${listing.make} ${listing.model}` === model) {
60+
allListings.push({ ...listing, source: sourceData.source });
61+
}
62+
});
63+
});
64+
65+
// Sort by price
66+
allListings.sort((a, b) => a.price - b.price);
67+
68+
// Get new listings and filter for this model
69+
const newListings = findNewListings(data).filter(
70+
listing => `${listing.make} ${listing.model}` === model
71+
);
72+
73+
const listings = activeTab === 'all' ? allListings : newListings;
74+
const title = activeTab === 'all' ? 'All Listings' : 'New Listings';
75+
76+
return (
77+
<div className="model-listings-view">
78+
<div className="tabs">
79+
<button
80+
className={`tab ${activeTab === 'all' ? 'active' : ''}`}
81+
onClick={() => handleTabChange('all')}
82+
>
83+
All Listings ({allListings.length})
84+
</button>
85+
<button
86+
className={`tab ${activeTab === 'new' ? 'active' : ''}`}
87+
onClick={() => handleTabChange('new')}
88+
>
89+
New Listings ({newListings.length})
90+
</button>
91+
</div>
92+
<ListingsTable listings={listings} title={title} />
93+
</div>
94+
);
95+
}

src/components/NewListings.css

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
.new-listings {
2+
background: white;
3+
border-radius: 8px;
4+
padding: 1.5rem;
5+
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
6+
margin-top: 2rem;
7+
}
8+
9+
.new-listings-header {
10+
display: flex;
11+
justify-content: space-between;
12+
align-items: flex-start;
13+
margin-bottom: 1.5rem;
14+
gap: 2rem;
15+
}
16+
17+
.new-listings h2 {
18+
font-size: 1.5rem;
19+
margin: 0 0 0.25rem 0;
20+
color: #667eea;
21+
}
22+
23+
.new-listings .subtitle {
24+
color: #666;
25+
font-size: 0.875rem;
26+
margin: 0;
27+
}
28+
29+
.source-filter {
30+
display: flex;
31+
align-items: center;
32+
gap: 0.5rem;
33+
white-space: nowrap;
34+
}
35+
36+
.source-filter label {
37+
font-size: 0.875rem;
38+
color: #666;
39+
font-weight: 500;
40+
}
41+
42+
.source-filter select {
43+
padding: 0.5rem 1rem;
44+
border: 1px solid #e0e0e0;
45+
border-radius: 4px;
46+
font-size: 0.875rem;
47+
color: #333;
48+
background: white;
49+
cursor: pointer;
50+
transition: border-color 0.2s;
51+
}
52+
53+
.source-filter select:hover {
54+
border-color: #667eea;
55+
}
56+
57+
.source-filter select:focus {
58+
outline: none;
59+
border-color: #667eea;
60+
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
61+
}

src/components/NewListings.jsx

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import React, { useState, useEffect } from 'react';
2+
import ListingsTable from './ListingsTable';
3+
import { findNewListings } from '../services/dataLoader';
4+
import './NewListings.css';
5+
6+
export default function NewListings({ data }) {
7+
const [selectedSource, setSelectedSource] = useState('all');
8+
9+
// Load source filter from URL on mount
10+
useEffect(() => {
11+
const url = new URL(window.location);
12+
const source = url.searchParams.get('source');
13+
if (source) {
14+
setSelectedSource(source);
15+
}
16+
}, []);
17+
18+
// Handle browser back/forward
19+
useEffect(() => {
20+
const handlePopState = () => {
21+
const url = new URL(window.location);
22+
const source = url.searchParams.get('source') || 'all';
23+
setSelectedSource(source);
24+
};
25+
26+
window.addEventListener('popstate', handlePopState);
27+
return () => window.removeEventListener('popstate', handlePopState);
28+
}, []);
29+
30+
if (!data || data.length === 0) {
31+
return null;
32+
}
33+
34+
const allNewListings = findNewListings(data);
35+
36+
if (allNewListings.length === 0) {
37+
return null;
38+
}
39+
40+
// Get unique sources
41+
const sources = [...new Set(allNewListings.map(l => l.source))].sort();
42+
43+
// Filter listings by source
44+
const filteredListings = selectedSource === 'all'
45+
? allNewListings
46+
: allNewListings.filter(l => l.source === selectedSource);
47+
48+
const handleSourceChange = (source) => {
49+
setSelectedSource(source);
50+
const url = new URL(window.location);
51+
if (source === 'all') {
52+
url.searchParams.delete('source');
53+
} else {
54+
url.searchParams.set('source', source);
55+
}
56+
window.history.pushState({}, '', url);
57+
};
58+
59+
return (
60+
<div className="new-listings">
61+
<div className="new-listings-header">
62+
<div>
63+
<h2>New Listings</h2>
64+
<p className="subtitle">Recently added vehicles from the latest scrape</p>
65+
</div>
66+
<div className="source-filter">
67+
<label htmlFor="source-select">Filter by source:</label>
68+
<select
69+
id="source-select"
70+
value={selectedSource}
71+
onChange={(e) => handleSourceChange(e.target.value)}
72+
>
73+
<option value="all">All Sources ({allNewListings.length})</option>
74+
{sources.map(source => {
75+
const count = allNewListings.filter(l => l.source === source).length;
76+
const displayName = source.charAt(0).toUpperCase() + source.slice(1);
77+
return (
78+
<option key={source} value={source}>
79+
{displayName} ({count})
80+
</option>
81+
);
82+
})}
83+
</select>
84+
</div>
85+
</div>
86+
<ListingsTable
87+
listings={filteredListings}
88+
title=""
89+
emptyMessage="No new listings found"
90+
showModel={true}
91+
/>
92+
</div>
93+
);
94+
}

0 commit comments

Comments
 (0)