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

@@ -4,11 +4,12 @@
import { onMount } from 'svelte';
import { newEnglandStates } from '../../../../lib/states';
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 };
let countyData: County | null = null;
let listings: Listing[] = [];
let oilPrices: OilPrice[] = [];
let loading = true;
let listingsLoading = false;
let error: string | null = null;
@@ -18,34 +19,40 @@
onMount(async () => {
const result = await api.state.getCounty(stateSlug, countySlug);
if (result.error) {
error = result.error;
countyData = null;
} else {
countyData = result.data;
// Fetch listings for this county
await fetchListings();
// Fetch both listings and oil prices in parallel
await fetchAllPrices();
}
loading = false;
});
async function fetchListings() {
async function fetchAllPrices() {
if (!countyData) return;
listingsLoading = true;
listingsError = null;
const result = await api.listings.getByCounty(countyData.id);
if (result.error) {
const [listingsResult, oilPricesResult] = await Promise.all([
api.listings.getByCounty(countyData.id),
api.oilPrices.getByCounty(countyData.id)
]);
if (listingsResult.error) {
listingsError = 'Failed to load listings';
} else {
listings = result.data || [];
listings = listingsResult.data || [];
sortListings();
}
// Oil prices failure is non-critical - just show empty
oilPrices = oilPricesResult.data || [];
listingsLoading = false;
}
@@ -113,114 +120,175 @@
<a href="/{stateSlug}" class="btn btn-primary mt-6">Back to {getStateName(countyData.state)}</a>
</div>
<!-- Listings Table -->
<div class="mt-8">
{#if listingsLoading}
<div class="flex justify-center">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else if listingsError}
<div class="alert alert-error">
<span>{listingsError}</span>
</div>
{:else if listings.length === 0}
<div class="text-center py-8">
<p class="text-lg text-gray-600">No active listings found for this county.</p>
</div>
{:else}
<!-- Sort Controls -->
<div class="mb-4 flex flex-wrap gap-2">
<select class="select select-bordered select-sm" bind:value={sortColumn} on:change={sortListings}>
<option value="company_name">Company</option>
<option value="town">Town</option>
<option value="price_per_gallon">Price per Gallon</option>
<option value="bio_percent">Bio %</option>
<option value="service">Service</option>
<option value="phone">Contact</option>
<option value="last_edited">Last Updated</option>
</select>
<button class="btn btn-sm" on:click={() => { sortDirection = sortDirection === 'asc' ? 'desc' : 'asc'; sortListings(); }}>
{sortDirection === 'asc' ? '↑' : '↓'}
</button>
</div>
{#if listingsLoading}
<div class="flex justify-center mt-8">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else if listingsError}
<div class="alert alert-error mt-8">
<span>{listingsError}</span>
</div>
{:else}
<!-- Premium Dealers Section -->
<div class="mt-8">
<h2 class="text-2xl font-bold mb-4">Premium Dealers</h2>
<!-- Desktop Table View -->
<div class="hidden md:block overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
<th class="cursor-pointer hover:bg-base-200" on:click={() => handleSort('company_name')}>
Company
{#if sortColumn === 'company_name'}
{sortDirection === 'asc' ? '↑' : '↓'}
{/if}
</th>
<th class="cursor-pointer hover:bg-base-200" on:click={() => handleSort('town')}>
Town
{#if sortColumn === 'town'}
{sortDirection === 'asc' ? '↑' : '↓'}
{/if}
</th>
<th class="cursor-pointer hover:bg-base-200" on:click={() => handleSort('price_per_gallon')}>
Price per Gallon
{#if sortColumn === 'price_per_gallon'}
{sortDirection === 'asc' ? '↑' : '↓'}
{/if}
</th>
<th class="cursor-pointer hover:bg-base-200" on:click={() => handleSort('bio_percent')}>
Bio %
{#if sortColumn === 'bio_percent'}
{sortDirection === 'asc' ? '↑' : '↓'}
{/if}
</th>
<th class="cursor-pointer hover:bg-base-200" on:click={() => handleSort('service')}>
Service
{#if sortColumn === 'service'}
{sortDirection === 'asc' ? '↑' : '↓'}
{/if}
</th>
<th class="cursor-pointer hover:bg-base-200" on:click={() => handleSort('phone')}>
Contact
{#if sortColumn === 'phone'}
{sortDirection === 'asc' ? '↑' : '↓'}
{/if}
</th>
<th class="cursor-pointer hover:bg-base-200" on:click={() => handleSort('last_edited')}>
Last Updated
{#if sortColumn === 'last_edited'}
{sortDirection === 'asc' ? '↑' : '↓'}
{/if}
</th>
</tr>
</thead>
<tbody>
{#each listings as listing}
{#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>
{:else}
<!-- Sort Controls -->
<div class="mb-4 flex flex-wrap gap-2">
<select class="select select-bordered select-sm" bind:value={sortColumn} on:change={sortListings}>
<option value="company_name">Company</option>
<option value="town">Town</option>
<option value="price_per_gallon">Price per Gallon</option>
<option value="bio_percent">Bio %</option>
<option value="service">Service</option>
<option value="phone">Contact</option>
<option value="last_edited">Last Updated</option>
</select>
<button class="btn btn-sm" on:click={() => { sortDirection = sortDirection === 'asc' ? 'desc' : 'asc'; sortListings(); }}>
{sortDirection === 'asc' ? '↑' : '↓'}
</button>
</div>
<!-- Desktop Table View -->
<div class="hidden md:block overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
<td>{listing.company_name}</td>
<td>{listing.town || 'N/A'}</td>
<td>
<div class="text-sm">
<div><strong>Card:</strong> ${listing.price_per_gallon.toFixed(2)}</div>
<div><strong>Cash:</strong> {listing.price_per_gallon_cash ? `$${listing.price_per_gallon_cash.toFixed(2)}` : 'N/A'}</div>
</div>
</td>
<td>{listing.bio_percent}%</td>
<td>
{#if listing.service}
<span class="badge badge-success">Yes</span>
{:else}
<span class="badge badge-neutral">No</span>
<th class="cursor-pointer hover:bg-base-200" on:click={() => handleSort('company_name')}>
Company
{#if sortColumn === 'company_name'}
{sortDirection === 'asc' ? '↑' : '↓'}
{/if}
</td>
<td>
<div class="text-sm">
{#if listing.phone}
<div><strong>Phone:</strong> <a href="tel:{listing.phone}" class="text-blue-600 hover:underline">{listing.phone}</a></div>
</th>
<th class="cursor-pointer hover:bg-base-200" on:click={() => handleSort('town')}>
Town
{#if sortColumn === 'town'}
{sortDirection === 'asc' ? '↑' : '↓'}
{/if}
</th>
<th class="cursor-pointer hover:bg-base-200" on:click={() => handleSort('price_per_gallon')}>
Price per Gallon
{#if sortColumn === 'price_per_gallon'}
{sortDirection === 'asc' ? '↑' : '↓'}
{/if}
</th>
<th class="cursor-pointer hover:bg-base-200" on:click={() => handleSort('bio_percent')}>
Bio %
{#if sortColumn === 'bio_percent'}
{sortDirection === 'asc' ? '↑' : '↓'}
{/if}
</th>
<th class="cursor-pointer hover:bg-base-200" on:click={() => handleSort('service')}>
Service
{#if sortColumn === 'service'}
{sortDirection === 'asc' ? '↑' : '↓'}
{/if}
</th>
<th class="cursor-pointer hover:bg-base-200" on:click={() => handleSort('phone')}>
Contact
{#if sortColumn === 'phone'}
{sortDirection === 'asc' ? '↑' : '↓'}
{/if}
</th>
<th class="cursor-pointer hover:bg-base-200" on:click={() => handleSort('last_edited')}>
Last Updated
{#if sortColumn === 'last_edited'}
{sortDirection === 'asc' ? '↑' : '↓'}
{/if}
</th>
</tr>
</thead>
<tbody>
{#each listings as listing}
<tr>
<td>{listing.company_name}</td>
<td>{listing.town || 'N/A'}</td>
<td>
<div class="text-sm">
<div><strong>Card:</strong> ${listing.price_per_gallon.toFixed(2)}</div>
<div><strong>Cash:</strong> {listing.price_per_gallon_cash ? `$${listing.price_per_gallon_cash.toFixed(2)}` : 'N/A'}</div>
</div>
</td>
<td>{listing.bio_percent}%</td>
<td>
{#if listing.service}
<span class="badge badge-success">Yes</span>
{:else}
<div><strong>Phone:</strong> N/A</div>
<span class="badge badge-neutral">No</span>
{/if}
<div>
<strong>Online:</strong>
</td>
<td>
<div class="text-sm">
{#if listing.phone}
<div><strong>Phone:</strong> <a href="tel:{listing.phone}" class="text-blue-600 hover:underline">{listing.phone}</a></div>
{:else}
<div><strong>Phone:</strong> N/A</div>
{/if}
<div>
<strong>Online:</strong>
{#if listing.online_ordering === 'none'}
No
{:else if listing.online_ordering === 'online_only'}
Online Only
{:else if listing.online_ordering === 'both'}
Both
{/if}
</div>
</div>
</td>
<td>{listing.last_edited ? new Date(listing.last_edited).toLocaleDateString() : 'N/A'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Mobile Card View -->
<div class="block md:hidden space-y-4">
{#each listings as listing}
<div class="card bg-base-100 shadow-lg">
<div class="card-body p-4">
<h3 class="card-title text-lg font-bold">
{listing.company_name}
{#if listing.town}
<br><small class="text-gray-500 font-normal">{listing.town}</small>
{/if}
</h3>
<div class="grid grid-cols-2 gap-2 text-sm">
<div>
<span class="font-semibold">Card Price:</span><br>
${listing.price_per_gallon.toFixed(2)}
</div>
<div>
<span class="font-semibold">Cash Price:</span><br>
{listing.price_per_gallon_cash ? `$${listing.price_per_gallon_cash.toFixed(2)}` : 'N/A'}
</div>
<div>
<span class="font-semibold">Bio %:</span><br>
{listing.bio_percent}%
</div>
<div>
<span class="font-semibold">Service:</span><br>
{#if listing.service}
<span class="badge badge-success badge-sm">Yes</span>
{:else}
<span class="badge badge-neutral badge-sm">No</span>
{/if}
</div>
<div>
<span class="font-semibold">Contact:</span><br>
{#if listing.phone}
<a href="tel:{listing.phone}" class="text-blue-600 hover:underline text-sm">{listing.phone}</a><br>
{:else}
N/A<br>
{/if}
<small>
Online:
{#if listing.online_ordering === 'none'}
No
{:else if listing.online_ordering === 'online_only'}
@@ -228,77 +296,73 @@
{:else if listing.online_ordering === 'both'}
Both
{/if}
</div>
</small>
</div>
<div>
<span class="font-semibold">Last Updated:</span><br>
<small>{listing.last_edited ? new Date(listing.last_edited).toLocaleDateString() : 'N/A'}</small>
</div>
</td>
<td>{listing.last_edited ? new Date(listing.last_edited).toLocaleDateString() : 'N/A'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Mobile Card View -->
<div class="block md:hidden space-y-4">
{#each listings as listing}
<div class="card bg-base-100 shadow-lg">
<div class="card-body p-4">
<h3 class="card-title text-lg font-bold">
{listing.company_name}
{#if listing.town}
<br><small class="text-gray-500 font-normal">{listing.town}</small>
{/if}
</h3>
<div class="grid grid-cols-2 gap-2 text-sm">
<div>
<span class="font-semibold">Card Price:</span><br>
${listing.price_per_gallon.toFixed(2)}
</div>
<div>
<span class="font-semibold">Cash Price:</span><br>
{listing.price_per_gallon_cash ? `$${listing.price_per_gallon_cash.toFixed(2)}` : 'N/A'}
</div>
<div>
<span class="font-semibold">Bio %:</span><br>
{listing.bio_percent}%
</div>
<div>
<span class="font-semibold">Service:</span><br>
{#if listing.service}
<span class="badge badge-success badge-sm">Yes</span>
{:else}
<span class="badge badge-neutral badge-sm">No</span>
{/if}
</div>
<div>
<span class="font-semibold">Contact:</span><br>
{#if listing.phone}
<a href="tel:{listing.phone}" class="text-blue-600 hover:underline text-sm">{listing.phone}</a><br>
{:else}
N/A<br>
{/if}
<small>
Online:
{#if listing.online_ordering === 'none'}
No
{:else if listing.online_ordering === 'online_only'}
Online Only
{:else if listing.online_ordering === 'both'}
Both
{/if}
</small>
</div>
<div>
<span class="font-semibold">Last Updated:</span><br>
<small>{listing.last_edited ? new Date(listing.last_edited).toLocaleDateString() : 'N/A'}</small>
</div>
</div>
</div>
</div>
{/each}
{/each}
</div>
{/if}
</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}
</div>
<!-- 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}
<div class="text-center py-10">
<h1 class="text-3xl font-bold text-error">County Not Found</h1>