feat(CRIT-010): show market prices on county pages alongside premium dealers

Add OilPrice type and oilPrices.getByCounty() API client method. County
page now fetches both premium listings and scraped oil prices in parallel,
displaying them in separate sections with appropriate table/card views.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-09 18:31:55 -05:00
parent a5df1bcacb
commit 27d22aefd4
4 changed files with 271 additions and 181 deletions

View File

@@ -12,6 +12,7 @@ import type {
Listing, Listing,
CreateListingRequest, CreateListingRequest,
UpdateListingRequest, UpdateListingRequest,
OilPrice,
County, County,
ServiceCategory ServiceCategory
} from './types'; } from './types';
@@ -265,6 +266,18 @@ export const listingsApi = {
} }
}; };
/**
* Oil Prices API methods (scraped market data, public)
*/
export const oilPricesApi = {
/**
* Get oil prices for a county (public)
*/
async getByCounty(countyId: number): Promise<ApiResponse<OilPrice[]>> {
return request<OilPrice[]>(`/oil-prices/county/${countyId}`);
}
};
/** /**
* State/County API methods * State/County API methods
*/ */
@@ -304,6 +317,7 @@ export const api = {
company: companyApi, company: companyApi,
listing: listingApi, listing: listingApi,
listings: listingsApi, listings: listingsApi,
oilPrices: oilPricesApi,
state: stateApi, state: stateApi,
categories: categoriesApi categories: categoriesApi
}; };

View File

@@ -1,3 +1,3 @@
// API Client exports // API Client exports
export { api, authApi, companyApi, listingApi, listingsApi, stateApi, categoriesApi } from './client'; export { api, authApi, companyApi, listingApi, listingsApi, oilPricesApi, stateApi, categoriesApi } from './client';
export * from './types'; export * from './types';

View File

@@ -99,6 +99,18 @@ export interface CreateListingRequest {
export type UpdateListingRequest = Partial<CreateListingRequest>; export type UpdateListingRequest = Partial<CreateListingRequest>;
// Oil Price Types (scraped market data)
export interface OilPrice {
id: number;
state: string | null;
zone: number | null;
name: string | null;
price: number | null;
date: string | null;
scrapetimestamp: string | null;
county_id: number | null;
}
// State/County Types // State/County Types
export interface County { export interface County {
id: number; id: number;

View File

@@ -4,11 +4,12 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { newEnglandStates } from '../../../../lib/states'; import { newEnglandStates } from '../../../../lib/states';
import { api } from '$lib/api'; import { api } from '$lib/api';
import type { Listing, County } from '$lib/api'; import type { Listing, OilPrice, County } from '$lib/api';
const { stateSlug, countySlug } = $page.params as { stateSlug: string; countySlug: string }; const { stateSlug, countySlug } = $page.params as { stateSlug: string; countySlug: string };
let countyData: County | null = null; let countyData: County | null = null;
let listings: Listing[] = []; let listings: Listing[] = [];
let oilPrices: OilPrice[] = [];
let loading = true; let loading = true;
let listingsLoading = false; let listingsLoading = false;
let error: string | null = null; let error: string | null = null;
@@ -24,28 +25,34 @@
countyData = null; countyData = null;
} else { } else {
countyData = result.data; countyData = result.data;
// Fetch listings for this county // Fetch both listings and oil prices in parallel
await fetchListings(); await fetchAllPrices();
} }
loading = false; loading = false;
}); });
async function fetchListings() { async function fetchAllPrices() {
if (!countyData) return; if (!countyData) return;
listingsLoading = true; listingsLoading = true;
listingsError = null; listingsError = null;
const result = await api.listings.getByCounty(countyData.id); const [listingsResult, oilPricesResult] = await Promise.all([
api.listings.getByCounty(countyData.id),
api.oilPrices.getByCounty(countyData.id)
]);
if (result.error) { if (listingsResult.error) {
listingsError = 'Failed to load listings'; listingsError = 'Failed to load listings';
} else { } else {
listings = result.data || []; listings = listingsResult.data || [];
sortListings(); sortListings();
} }
// Oil prices failure is non-critical - just show empty
oilPrices = oilPricesResult.data || [];
listingsLoading = false; listingsLoading = false;
} }
@@ -113,19 +120,22 @@
<a href="/{stateSlug}" class="btn btn-primary mt-6">Back to {getStateName(countyData.state)}</a> <a href="/{stateSlug}" class="btn btn-primary mt-6">Back to {getStateName(countyData.state)}</a>
</div> </div>
<!-- Listings Table -->
<div class="mt-8">
{#if listingsLoading} {#if listingsLoading}
<div class="flex justify-center"> <div class="flex justify-center mt-8">
<span class="loading loading-spinner loading-lg"></span> <span class="loading loading-spinner loading-lg"></span>
</div> </div>
{:else if listingsError} {:else if listingsError}
<div class="alert alert-error"> <div class="alert alert-error mt-8">
<span>{listingsError}</span> <span>{listingsError}</span>
</div> </div>
{:else if listings.length === 0} {:else}
<div class="text-center py-8"> <!-- Premium Dealers Section -->
<p class="text-lg text-gray-600">No active listings found for this county.</p> <div class="mt-8">
<h2 class="text-2xl font-bold mb-4">Premium Dealers</h2>
{#if listings.length === 0}
<div class="text-center py-6">
<p class="text-gray-600">No premium dealers listed for this county yet.</p>
</div> </div>
{:else} {:else}
<!-- Sort Controls --> <!-- Sort Controls -->
@@ -299,6 +309,60 @@
</div> </div>
{/if} {/if}
</div> </div>
<!-- Market Prices Section (scraped oil prices) -->
{#if oilPrices.length > 0}
<div class="mt-12">
<h2 class="text-2xl font-bold mb-4">Market Prices</h2>
<!-- Desktop Table View -->
<div class="hidden md:block overflow-x-auto">
<table class="table w-full">
<thead>
<tr>
<th>Company Name</th>
<th>Price</th>
<th>Date Posted</th>
</tr>
</thead>
<tbody>
{#each oilPrices as op}
<tr>
<td>{op.name || 'Unknown'}</td>
<td>{op.price != null ? `$${op.price.toFixed(2)}` : 'N/A'}</td>
<td>{op.date || 'N/A'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Mobile Card View -->
<div class="block md:hidden space-y-3">
{#each oilPrices as op}
<div class="card bg-base-100 shadow">
<div class="card-body p-3">
<div class="flex justify-between items-center">
<span class="font-semibold">{op.name || 'Unknown'}</span>
<span class="text-lg font-bold">{op.price != null ? `$${op.price.toFixed(2)}` : 'N/A'}</span>
</div>
<div class="text-sm text-gray-500">
Posted: {op.date || 'N/A'}
</div>
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- Empty state when both sections are empty -->
{#if listings.length === 0 && oilPrices.length === 0}
<div class="text-center py-8 mt-4">
<p class="text-lg text-gray-600">No pricing data available for this county yet.</p>
</div>
{/if}
{/if}
{:else if error} {:else if error}
<div class="text-center py-10"> <div class="text-center py-10">
<h1 class="text-3xl font-bold text-error">County Not Found</h1> <h1 class="text-3xl font-bold text-error">County Not Found</h1>