Files
frontend/src/routes/(app)/[stateSlug]/[countySlug]/+page.svelte
Edwin Eames 7ac2c7c59e feat: add admin panel, stats API, and url support across frontend
- Add adminApi with full CRUD for users, companies, listings, oil-prices
- Add statsApi for fetching latest market price aggregates
- Add AdminTable component and /admin page for site management
- Add StatsPrice, UpdateUserRequest, UpdateOilPriceRequest types
- Add url field support in listing create/edit forms
- Update state SVG data for all 6 New England states
- Update county page to display richer listing info (phone, url, bio%)
- Misc layout and style updates across vendor and public routes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 11:34:31 -05:00

967 lines
52 KiB
Svelte
Executable File

<!-- src/routes/(app)/[stateSlug]/[countySlug]/+page.svelte -->
<script lang="ts">
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { newEnglandStates } from '../../../../lib/states';
import { api } from '$lib/api';
import type { Listing, OilPrice, County } from '$lib/api';
import { fade, fly } from 'svelte/transition';
import {
ChevronLeft,
ChevronUp,
ChevronDown,
Flame,
Phone,
MapPin,
DollarSign,
Droplets,
Wrench,
Globe,
Clock,
Info,
AlertCircle,
} from 'lucide-svelte';
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;
let listingsError: string | null = null;
let sortColumn = 'price_per_gallon';
let sortDirection = 'asc';
// Market Prices sorting
let marketSortColumn = 'date';
let marketSortDirection = 'desc';
// Staggered section reveal
let headerVisible = false;
let premiumVisible = false;
let marketVisible = false;
onMount(async () => {
const result = await api.state.getCounty(stateSlug, countySlug);
if (result.error) {
error = result.error;
countyData = null;
} else {
countyData = result.data;
await fetchAllPrices();
}
loading = false;
// Stagger section reveals
headerVisible = true;
setTimeout(() => { premiumVisible = true; }, 250);
setTimeout(() => { marketVisible = true; }, 500);
});
async function fetchAllPrices() {
if (!countyData) return;
listingsLoading = true;
listingsError = null;
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 = listingsResult.data || [];
sortListings();
}
// Oil prices failure is non-critical - just show empty
oilPrices = (oilPricesResult.data || []).filter(p => p.price !== 0);
sortMarketPrices(); // Sort immediately after fetching
listingsLoading = false;
}
function sortListings() {
listings = [...listings].sort((a, b) => {
let aValue = a[sortColumn as keyof Listing];
let bValue = b[sortColumn as keyof Listing];
// Handle string sorting
if (typeof aValue === 'string' && typeof bValue === 'string') {
const comparison = aValue.localeCompare(bValue);
return sortDirection === 'asc' ? comparison : -comparison;
}
// Handle number sorting
if (typeof aValue === 'number' && typeof bValue === 'number') {
return sortDirection === 'asc' ? aValue - bValue : bValue - aValue;
}
// Handle boolean sorting
if (typeof aValue === 'boolean' && typeof bValue === 'boolean') {
const aNum = aValue ? 1 : 0;
const bNum = bValue ? 1 : 0;
return sortDirection === 'asc' ? aNum - bNum : bNum - aNum;
}
return 0;
});
}
function handleSort(column: string) {
if (sortColumn === column) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
} else {
sortColumn = column;
sortDirection = 'asc';
}
sortListings();
}
function sortMarketPrices() {
oilPrices = [...oilPrices].sort((a, b) => {
// Handle date sorting specifically
if (marketSortColumn === 'date') {
// Prefer scrapetimestamp for accurate sorting, fallback to date string parsing
const timeA = a.scrapetimestamp ? new Date(a.scrapetimestamp).getTime() : (a.date ? new Date(a.date).getTime() : 0);
const timeB = b.scrapetimestamp ? new Date(b.scrapetimestamp).getTime() : (b.date ? new Date(b.date).getTime() : 0);
const valA = isNaN(timeA) ? 0 : timeA;
const valB = isNaN(timeB) ? 0 : timeB;
return marketSortDirection === 'asc' ? valA - valB : valB - valA;
}
let aValue: any = a[marketSortColumn as keyof OilPrice];
let bValue: any = b[marketSortColumn as keyof OilPrice];
if (marketSortColumn === 'price') {
// Sort null prices to the bottom regardless of direction usually, or treat as extreme?
// Let's treat null as Infinity for Asc (Low to High) so it goes to bottom
// And -Infinity for Desc (High to Low) so it goes to bottom?
// Actually standard practice is nulls last.
const pA = a.price ?? (marketSortDirection === 'asc' ? Infinity : -Infinity);
const pB = b.price ?? (marketSortDirection === 'asc' ? Infinity : -Infinity);
return marketSortDirection === 'asc' ? pA - pB : pB - pA;
}
// Fallback string compare (e.g. name)
if (typeof aValue === 'string' && typeof bValue === 'string') {
return marketSortDirection === 'asc'
? aValue.localeCompare(bValue)
: bValue.localeCompare(aValue);
}
return 0;
});
}
function handleMarketSort(event: Event) {
const target = event.target as HTMLSelectElement;
const value = target.value;
// Split value into column and direction if needed, or manageable logic
// The dropdown values will be like "price-asc", "date-desc", etc.
const [column, direction] = value.split('-');
if (column && direction) {
marketSortColumn = column;
marketSortDirection = direction;
sortMarketPrices();
}
}
function getStateName(stateAbbr: string): string {
const state = newEnglandStates.find(s => s.id === stateAbbr);
return state ? state.name : stateAbbr;
}
/**
* Format a date string into a friendly relative or short date.
*/
function formatDate(dateStr: string | undefined | null): string {
if (!dateStr) return 'N/A';
try {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Today';
if (diffDays === 1) return 'Yesterday';
if (diffDays < 7) return `${diffDays} days ago`;
if (diffDays < 30) {
const weeks = Math.floor(diffDays / 7);
return `${weeks} week${weeks > 1 ? 's' : ''} ago`;
}
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
} catch {
return dateStr;
}
}
/**
* Format online ordering status into human-readable text.
*/
function formatOnlineOrdering(value: string): string {
if (value === 'online_only') return 'Online Only';
if (value === 'both') return 'Phone & Online';
return 'Phone Only';
}
function formatScrapeDate(dateStr: string | null | undefined): string {
if (!dateStr) return '';
try {
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', { month: '2-digit', day: '2-digit', year: '2-digit' });
} catch {
return '';
}
}
</script>
<!-- SEO -->
<svelte:head>
{#if countyData}
<title>Heating Oil Prices in {countyData.name}, {getStateName(countyData.state)} | Biz Hero</title>
<meta name="description" content="Compare heating oil and biofuel prices from local dealers in {countyData.name}, {getStateName(countyData.state)}. Find the best deals today." />
{:else}
<title>County Not Found | Biz Hero</title>
{/if}
</svelte:head>
{#if loading}
<!-- ============================================================ -->
<!-- LOADING STATE -->
<!-- ============================================================ -->
<div class="py-6 px-2">
<!-- Skeleton breadcrumb -->
<div class="skeleton-box h-4 w-48 mb-6"></div>
<!-- Skeleton header -->
<div class="text-center pb-6">
<div class="skeleton-box h-8 w-32 mx-auto mb-4 rounded-full"></div>
<div class="skeleton-box h-10 w-64 mx-auto mb-3"></div>
<div class="skeleton-box h-5 w-80 mx-auto"></div>
</div>
<!-- Skeleton table area -->
<div class="price-section mt-4">
<div class="skeleton-box h-7 w-44 mb-4"></div>
<div class="space-y-3">
{#each Array(4) as _, i}
<div class="skeleton-box h-20 stagger-{(i % 6) + 1} opacity-0 animate-fade-in"></div>
{/each}
</div>
</div>
</div>
{:else if countyData}
<!-- ============================================================ -->
<!-- BREADCRUMB -->
<!-- ============================================================ -->
<nav class="mb-6 px-2" aria-label="Breadcrumb">
<ol class="flex items-center gap-1 text-sm">
<li>
<a
href="/"
class="inline-flex items-center gap-1 text-primary hover:underline focus:outline-none focus:ring-2 focus:ring-primary rounded"
>
<ChevronLeft size={14} />
<span>All States</span>
</a>
</li>
<li class="text-base-content/40" aria-hidden="true">/</li>
<li>
<a
href="/{stateSlug}"
class="text-primary hover:underline focus:outline-none focus:ring-2 focus:ring-primary rounded"
>
{getStateName(countyData.state)}
</a>
</li>
<li class="text-base-content/40" aria-hidden="true">/</li>
<li class="font-medium text-base-content" aria-current="page">{countyData.name}</li>
</ol>
</nav>
<!-- ============================================================ -->
<!-- COUNTY HEADER -->
<!-- ============================================================ -->
{#if headerVisible}
<section class="text-center pb-4 md:pb-6 px-2" in:fly={{ y: 24, duration: 500 }}>
<!-- Accent badge -->
<div class="flex justify-center mb-4">
<div class="accent-badge">
<Flame size={18} class="text-oil-orange-500" />
<span>Heating Oil Prices</span>
</div>
</div>
<!-- County name -->
<h1 class="text-3xl sm:text-4xl md:text-5xl font-bold leading-tight text-base-content mb-2 px-2">
{countyData.name}
</h1>
<!-- State context -->
<p class="text-lg sm:text-xl text-base-content/70 max-w-xl mx-auto leading-relaxed px-4 mb-1">
{getStateName(countyData.state)}
</p>
<!-- Subtitle -->
<p class="text-base text-base-content/50 max-w-xl mx-auto leading-relaxed px-4">
Compare heating oil prices from local dealers
</p>
</section>
{/if}
<!-- ============================================================ -->
<!-- LISTINGS CONTENT -->
<!-- ============================================================ -->
{#if listingsLoading}
<!-- Skeleton loading for listings -->
<div class="px-2 mt-4">
<div class="price-section">
<div class="skeleton-box h-7 w-44 mb-4"></div>
<div class="space-y-3">
{#each Array(3) as _, i}
<div class="skeleton-box h-24 stagger-{(i % 6) + 1} opacity-0 animate-fade-in"></div>
{/each}
</div>
</div>
</div>
{:else if listingsError}
<!-- Error state -->
<div class="max-w-lg mx-auto px-2 mt-4">
<div class="alert alert-error shadow-sm">
<AlertCircle size={20} />
<div>
<h3 class="font-bold">Could not load dealer prices</h3>
<p class="text-sm">{listingsError}</p>
</div>
</div>
</div>
{:else}
<!-- ============================================================ -->
<!-- PREMIUM DEALERS SECTION -->
<!-- ============================================================ -->
{#if premiumVisible && listings.length > 0}
<section class="px-2 mt-4" in:fly={{ y: 20, duration: 400 }}>
<div class="price-section">
<!-- Section header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-4">
<div>
<h2 class="text-2xl sm:text-3xl font-bold text-base-content flex items-center gap-2">
<div class="value-icon !w-9 !h-9 sm:!w-10 sm:!h-10 flex-shrink-0">
<DollarSign size={20} />
</div>
Premium Dealers
</h2>
<p class="text-base text-base-content/50 mt-1">
Verified local dealers with current pricing
</p>
</div>
{#if listings.length > 0}
<div class="text-sm text-base-content/40">
{listings.length} dealer{listings.length !== 1 ? 's' : ''} listed
</div>
{/if}
</div>
{#if listings.length === 0}
<!-- Empty state -->
<div class="text-center py-10">
<div class="value-icon mx-auto mb-4">
<Flame size={28} />
</div>
<p class="text-lg font-semibold text-base-content mb-1">No premium dealers listed yet</p>
<p class="text-base text-base-content/50">Check back soon for local dealer pricing.</p>
</div>
{:else}
<!-- ======================== -->
<!-- DESKTOP TABLE -->
<!-- ======================== -->
<div class="hidden lg:block">
<div class="price-table-wrap">
<table class="w-full">
<thead>
<tr class="border-b border-base-300 bg-base-200/50">
<th class="text-left px-4 py-3">
<button
class="sort-header inline-flex items-center gap-1"
class:is-active={sortColumn === 'company_name'}
on:click={() => handleSort('company_name')}
>
Company
{#if sortColumn === 'company_name'}
{#if sortDirection === 'asc'}
<ChevronUp size={14} />
{:else}
<ChevronDown size={14} />
{/if}
{/if}
</button>
</th>
<th class="text-left px-4 py-3">
<button
class="sort-header inline-flex items-center gap-1"
class:is-active={sortColumn === 'town'}
on:click={() => handleSort('town')}
>
Town
{#if sortColumn === 'town'}
{#if sortDirection === 'asc'}
<ChevronUp size={14} />
{:else}
<ChevronDown size={14} />
{/if}
{/if}
</button>
</th>
<th class="text-left px-4 py-3">
<button
class="sort-header inline-flex items-center gap-1"
class:is-active={sortColumn === 'price_per_gallon'}
on:click={() => handleSort('price_per_gallon')}
>
Price / Gal
{#if sortColumn === 'price_per_gallon'}
{#if sortDirection === 'asc'}
<ChevronUp size={14} />
{:else}
<ChevronDown size={14} />
{/if}
{/if}
</button>
</th>
<th class="text-left px-4 py-3">
<button
class="sort-header inline-flex items-center gap-1"
class:is-active={sortColumn === 'bio_percent'}
on:click={() => handleSort('bio_percent')}
>
Bio %
{#if sortColumn === 'bio_percent'}
{#if sortDirection === 'asc'}
<ChevronUp size={14} />
{:else}
<ChevronDown size={14} />
{/if}
{/if}
</button>
</th>
<th class="text-left px-4 py-3">
<button
class="sort-header inline-flex items-center gap-1"
class:is-active={sortColumn === 'service'}
on:click={() => handleSort('service')}
>
Service
{#if sortColumn === 'service'}
{#if sortDirection === 'asc'}
<ChevronUp size={14} />
{:else}
<ChevronDown size={14} />
{/if}
{/if}
</button>
</th>
<th class="text-left px-4 py-3">
<button
class="sort-header inline-flex items-center gap-1"
class:is-active={sortColumn === 'phone'}
on:click={() => handleSort('phone')}
>
Contact
{#if sortColumn === 'phone'}
{#if sortDirection === 'asc'}
<ChevronUp size={14} />
{:else}
<ChevronDown size={14} />
{/if}
{/if}
</button>
</th>
<th class="text-left px-4 py-3">
<button
class="sort-header inline-flex items-center gap-1"
class:is-active={sortColumn === 'last_edited'}
on:click={() => handleSort('last_edited')}
>
Updated
{#if sortColumn === 'last_edited'}
{#if sortDirection === 'asc'}
<ChevronUp size={14} />
{:else}
<ChevronDown size={14} />
{/if}
{/if}
</button>
</th>
</tr>
</thead>
<tbody>
{#each listings.filter(l => l.phone) as listing, i}
<tr
class="border-b border-base-300/50 last:border-b-0
{i % 2 === 1 ? 'bg-base-200/30' : ''}
hover:bg-primary/5 transition-colors duration-100"
>
<!-- Company name -->
<td class="px-4 py-4">
<div class="flex flex-col">
<span class="text-base font-semibold text-base-content">
{listing.company_name}
</span>
{#if listing.url}
<a href={listing.url} target="_blank" rel="noopener noreferrer" class="text-xs text-primary hover:underline flex items-center gap-1 mt-0.5" on:click|stopPropagation>
<Globe size={10} />
Visit Website
</a>
{/if}
</div>
</td>
<!-- Town -->
<td class="px-4 py-4">
<span class="text-sm text-base-content/70">
{listing.town || '--'}
</span>
</td>
<!-- Price (THE HERO) -->
<td class="px-4 py-4">
<div class="price-hero">
${listing.price_per_gallon.toFixed(2)}
</div>
{#if listing.price_per_gallon_cash}
<div class="price-secondary mt-0.5">
Cash: ${listing.price_per_gallon_cash.toFixed(2)}
</div>
{/if}
</td>
<!-- Bio % -->
<td class="px-4 py-4">
<div class="inline-flex items-center gap-1 text-sm text-base-content/70">
<Droplets size={14} class="text-primary/60" />
{listing.bio_percent}%
</div>
</td>
<!-- Service -->
<td class="px-4 py-4">
{#if listing.service}
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold bg-success/15 text-success">
<Wrench size={12} />
Yes
</span>
{:else}
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold bg-base-300/50 text-base-content/40">
No
</span>
{/if}
</td>
<!-- Contact -->
<td class="px-4 py-4">
<div class="space-y-1">
{#if listing.phone}
<a
href="tel:{listing.phone}"
class="inline-flex items-center gap-1 text-sm text-primary hover:underline font-medium"
>
<Phone size={13} />
{listing.phone}
</a>
{:else}
<span class="text-sm text-base-content/40"></span>
{/if}
<div class="text-xs text-base-content/40 flex items-center gap-1">
<Globe size={11} />
{formatOnlineOrdering(listing.online_ordering)}
</div>
</div>
</td>
<!-- Last Updated -->
<td class="px-4 py-4">
<span class="text-sm text-base-content/50 flex items-center gap-1">
<Clock size={13} />
{formatDate(listing.last_edited)}
</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
<!-- ======================== -->
<!-- MOBILE SORT CONTROLS -->
<!-- ======================== -->
<div class="lg:hidden mb-4 flex items-center gap-2">
<label for="mobile-sort" class="text-sm font-medium text-base-content/60 flex-shrink-0">Sort by:</label>
<select
id="mobile-sort"
class="select select-bordered select-sm flex-1 bg-base-100 text-base-content text-sm"
bind:value={sortColumn}
on:change={sortListings}
>
<option value="price_per_gallon">Price (Low to High)</option>
<option value="company_name">Company Name</option>
<option value="town">Town</option>
<option value="bio_percent">Bio %</option>
<option value="service">Service Available</option>
<option value="last_edited">Last Updated</option>
</select>
<button
class="btn btn-sm btn-square btn-ghost border border-base-300"
on:click={() => { sortDirection = sortDirection === 'asc' ? 'desc' : 'asc'; sortListings(); }}
aria-label="Toggle sort direction"
>
{#if sortDirection === 'asc'}
<ChevronUp size={16} />
{:else}
<ChevronDown size={16} />
{/if}
</button>
</div>
<!-- ======================== -->
<!-- MOBILE CARDS -->
<!-- ======================== -->
<div class="lg:hidden space-y-3">
{#each listings.filter(l => l.phone) as listing, i}
<div class="listing-card stagger-{(i % 6) + 1} opacity-0 animate-fade-in-up">
<!-- Top row: Company + Price -->
<div class="flex items-start justify-between gap-3 mb-3">
<div class="min-w-0 flex-1">
<h3 class="text-lg font-bold text-base-content leading-tight truncate">
{listing.company_name}
</h3>
{#if listing.url}
<a href={listing.url} target="_blank" rel="noopener noreferrer" class="text-xs text-primary hover:underline flex items-center gap-1 mt-0.5" on:click|stopPropagation>
<Globe size={11} />
Visit Website
</a>
{/if}
{#if listing.town}
<p class="text-sm text-base-content/50 flex items-center gap-1 mt-0.5">
<MapPin size={13} />
{listing.town}
</p>
{/if}
</div>
<div class="text-right flex-shrink-0">
<div class="price-hero !text-2xl">
${listing.price_per_gallon.toFixed(2)}
</div>
<div class="text-xs text-base-content/40 mt-0.5">per gallon</div>
</div>
</div>
<!-- Cash price callout (if different) -->
{#if listing.price_per_gallon_cash}
<div class="flex items-center gap-2 mb-3 px-3 py-2 rounded-lg bg-success/8 border border-success/15">
<DollarSign size={15} class="text-success flex-shrink-0" />
<span class="text-sm font-semibold text-base-content">
Cash Price: ${listing.price_per_gallon_cash.toFixed(2)}/gal
</span>
</div>
{/if}
<!-- Details grid -->
<div class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm border-t border-base-300/50 pt-3">
<!-- Bio % -->
<div class="flex items-center gap-1.5">
<Droplets size={14} class="text-primary/60 flex-shrink-0" />
<span class="text-base-content/60">Bio:</span>
<span class="font-medium text-base-content">{listing.bio_percent}%</span>
</div>
<!-- Service -->
<div class="flex items-center gap-1.5">
<Wrench size={14} class="text-base-content/40 flex-shrink-0" />
<span class="text-base-content/60">Service:</span>
{#if listing.service}
<span class="font-medium text-success">Yes</span>
{:else}
<span class="text-base-content/40">No</span>
{/if}
</div>
<!-- Online ordering -->
<div class="flex items-center gap-1.5">
<Globe size={14} class="text-base-content/40 flex-shrink-0" />
<span class="text-base-content/60">Order:</span>
<span class="font-medium text-base-content">{formatOnlineOrdering(listing.online_ordering)}</span>
</div>
<!-- Last updated -->
<div class="flex items-center gap-1.5">
<Clock size={14} class="text-base-content/40 flex-shrink-0" />
<span class="text-base-content/60">{formatDate(listing.last_edited)}</span>
</div>
</div>
<!-- Phone CTA -->
{#if listing.phone}
<div class="mt-3 pt-3 border-t border-base-300/50">
<a
href="tel:{listing.phone}"
class="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg
bg-primary/10 text-primary font-semibold text-sm
hover:bg-primary/20 active:bg-primary/30
transition-colors duration-150 w-full justify-center sm:w-auto"
>
<Phone size={16} />
{listing.phone}
</a>
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</section>
{/if}
<!-- ============================================================ -->
<!-- MARKET PRICES SECTION (scraped data) -->
<!-- ============================================================ -->
{#if marketVisible && oilPrices.length > 0}
<section class="px-2 mt-6 sm:mt-8" in:fly={{ y: 20, duration: 400 }}>
<div class="price-section">
<!-- Section header -->
<div class="mb-4 flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h2 class="text-2xl sm:text-3xl font-bold text-base-content flex items-center gap-2">
<div class="value-icon !w-9 !h-9 sm:!w-10 sm:!h-10 flex-shrink-0">
<Flame size={20} />
</div>
Market Prices
</h2>
<div class="info-note mt-2">
<Info size={14} class="flex-shrink-0" />
<span>Market prices collected from public sources</span>
</div>
</div>
<!-- Sort Dropdown -->
<div class="flex items-center gap-2">
<label for="market-sort" class="text-sm font-medium text-base-content/60 flex-shrink-0 hidden md:block">Sort by:</label>
<select
id="market-sort"
class="select select-bordered select-sm w-full md:w-auto bg-base-100 text-base-content text-sm"
on:change={handleMarketSort}
>
<option value="date-desc" selected={marketSortColumn === 'date' && marketSortDirection === 'desc'}>Last Updated (Newest)</option>
<option value="price-asc" selected={marketSortColumn === 'price' && marketSortDirection === 'asc'}>Price (Low to High)</option>
<option value="price-desc" selected={marketSortColumn === 'price' && marketSortDirection === 'desc'}>Price (High to Low)</option>
<option value="name-asc" selected={marketSortColumn === 'name' && marketSortDirection === 'asc'}>Name (A-Z)</option>
</select>
</div>
</div>
<!-- Desktop table -->
<div class="hidden md:block">
<div class="price-table-wrap">
<table class="w-full">
<thead>
<tr class="border-b border-base-300 bg-base-200/50">
<th class="text-left px-4 py-3">
<span class="sort-header">Company</span>
</th>
<th class="text-left px-4 py-3">
<span class="sort-header">Price / Gal</span>
</th>
<th class="text-left px-4 py-3">
<span class="sort-header">Phone</span>
</th>
<th class="text-left px-4 py-3">
<span class="sort-header">URL</span>
</th>
<th class="text-left px-4 py-3">
<span class="sort-header">Last Updated</span>
</th>
</tr>
</thead>
<tbody>
{#each oilPrices.filter(op => op.phone) as op, i}
<tr
class="border-b border-base-300/50 last:border-b-0
{i % 2 === 1 ? 'bg-base-200/30' : ''}
hover:bg-primary/5 transition-colors duration-100"
>
<td class="px-4 py-3">
<span class="text-base font-semibold text-base-content">
{op.name || 'Unknown'}
</span>
</td>
<td class="px-4 py-3">
<span class="price-hero !text-xl">
{op.price != null ? `$${op.price.toFixed(2)}` : 'N/A'}
</span>
</td>
<td class="px-4 py-3">
{#if op.phone}
<a
href="tel:{op.phone}"
class="inline-flex items-center gap-1 text-sm text-primary hover:underline font-medium"
>
<Phone size={13} />
{op.phone}
</a>
{:else}
<span class="text-sm text-base-content/40"></span>
{/if}
</td>
<td class="px-4 py-3">
{#if op.url}
<a
href="{op.url}"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-sm text-primary hover:underline font-medium"
>
<Globe size={13} />
Visit
</a>
{:else}
<span class="text-sm text-base-content/40">N/A</span>
{/if}
</td>
<td class="px-4 py-3">
<span class="text-sm text-base-content/50 flex items-center gap-1">
<Clock size={13} />
{formatScrapeDate(op.scrapetimestamp) || op.date || 'N/A'}
</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
<!-- Mobile cards -->
<div class="md:hidden space-y-3">
{#each oilPrices.filter(op => op.phone) as op, i}
<div class="listing-card stagger-{(i % 6) + 1} opacity-0 animate-fade-in-up">
<div class="flex items-center justify-between gap-3 mb-2">
<div class="min-w-0 flex-1">
<span class="text-base font-semibold text-base-content block truncate">
{op.name || 'Unknown'}
</span>
<span class="text-xs text-base-content/40 flex items-center gap-1 mt-0.5">
<Clock size={11} />
Last Updated: {formatScrapeDate(op.scrapetimestamp) || op.date || 'N/A'}
</span>
</div>
<div class="price-hero !text-2xl flex-shrink-0">
{op.price != null ? `$${op.price.toFixed(2)}` : 'N/A'}
</div>
</div>
<!-- Contact info section -->
{#if op.phone || op.url}
<div class="border-t border-base-300/50 pt-2 mt-2 space-y-1">
{#if op.phone}
<a
href="tel:{op.phone}"
class="inline-flex items-center gap-1.5 text-sm text-primary hover:underline font-medium"
>
<Phone size={13} />
{op.phone}
</a>
{/if}
{#if op.url}
<div>
<a
href="{op.url}"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 text-sm text-primary hover:underline font-medium"
>
<Globe size={13} />
Visit Website
</a>
</div>
{/if}
</div>
{/if}
</div>
{/each}
</div>
</div>
</section>
{/if}
<!-- ============================================================ -->
<!-- EMPTY STATE (when both sections are empty) -->
<!-- ============================================================ -->
{#if listings.length === 0 && oilPrices.length === 0 && premiumVisible}
<section class="text-center py-12 px-2" in:fade={{ duration: 300 }}>
<div class="value-icon mx-auto mb-4">
<DollarSign size={28} />
</div>
<p class="text-xl font-semibold text-base-content mb-2">No pricing data available yet</p>
<p class="text-base text-base-content/50 max-w-sm mx-auto">
We are working on gathering pricing information for {countyData.name}. Check back soon!
</p>
</section>
{/if}
{/if}
<!-- ============================================================ -->
<!-- BACK NAVIGATION (subtle, bottom) -->
<!-- ============================================================ -->
<section class="pb-8 pt-6 text-center px-2">
<a
href="/{stateSlug}"
class="inline-flex items-center gap-1 text-sm text-base-content/50 hover:text-primary transition-colors focus:outline-none focus:ring-2 focus:ring-primary rounded px-2 py-1"
>
<ChevronLeft size={14} />
<span>Back to {getStateName(countyData.state)}</span>
</a>
</section>
{:else if error}
<!-- ============================================================ -->
<!-- ERROR STATE — County Not Found -->
<!-- ============================================================ -->
<div class="text-center py-16 px-2">
<div class="mb-6">
<div class="value-icon mx-auto mb-4">
<AlertCircle size={28} />
</div>
<h1 class="text-3xl font-bold text-error mb-3">County Not Found</h1>
<p class="text-base-content/60 text-lg max-w-md mx-auto">
{error}
</p>
</div>
<a href="/{stateSlug}" class="inline-flex items-center gap-1 text-primary hover:underline font-medium">
<ChevronLeft size={16} />
Back to state
</a>
</div>
{/if}