feat(CRIT-012): redesign home, state, and county pages for 55+ audience
Complete visual overhaul of the three core public pages, establishing a consistent design system with reusable CSS classes and theme tokens. Phase 1 — Home Page: - Hero section with flame badge, bold headline, bouncing arrow CTA - SVG map wrapped in warm-glow container with hover indicator - State navigation cards with per-state colors and hover lift - Value propositions grid (Compare, Find, Save, Free) - Subtle dealer login CTA Phase 2 — State Page: - Themed breadcrumb with ChevronLeft back-nav - Per-state accent colors on header - County map in .map-container with hover indicator - County navigation card grid with bidirectional map cross-highlighting - Skeleton loading and DaisyUI error states Phase 3 — County/Tables Page: - Price-hero styling makes pricing the dominant visual element - Sortable desktop table with chevron icons and active column highlight - Completely redesigned mobile cards: company + price top row, cash callout, icon-labeled details grid, tappable phone CTA - Market prices section with info note - Relative date formatting (Today, Yesterday, 3 days ago) - Full skeleton loading states Design System (shared across all pages): - 19 reusable CSS classes in app.postcss (.accent-badge, .map-container, .price-hero, .listing-card, .county-card, .skeleton-box, etc.) - oil-orange and oil-blue color scales in Tailwind config - fade-in-up, fade-in, float animations - Staggered section reveals with Svelte transitions - Zero hardcoded colors — all theme tokens and DaisyUI semantics - Full dark mode support throughout Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,22 @@
|
||||
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;
|
||||
@@ -15,7 +31,12 @@
|
||||
let error: string | null = null;
|
||||
let listingsError: string | null = null;
|
||||
let sortColumn = 'price_per_gallon';
|
||||
let sortDirection = 'asc'; // 'asc' or 'desc' - lowest price first
|
||||
let sortDirection = 'asc';
|
||||
|
||||
// Staggered section reveal
|
||||
let headerVisible = false;
|
||||
let premiumVisible = false;
|
||||
let marketVisible = false;
|
||||
|
||||
onMount(async () => {
|
||||
const result = await api.state.getCounty(stateSlug, countySlug);
|
||||
@@ -25,11 +46,15 @@
|
||||
countyData = null;
|
||||
} else {
|
||||
countyData = result.data;
|
||||
// Fetch both listings and oil prices in parallel
|
||||
await fetchAllPrices();
|
||||
}
|
||||
|
||||
loading = false;
|
||||
|
||||
// Stagger section reveals
|
||||
headerVisible = true;
|
||||
setTimeout(() => { premiumVisible = true; }, 250);
|
||||
setTimeout(() => { marketVisible = true; }, 500);
|
||||
});
|
||||
|
||||
async function fetchAllPrices() {
|
||||
@@ -97,276 +122,685 @@
|
||||
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';
|
||||
}
|
||||
</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}
|
||||
<div class="text-center py-10">
|
||||
<p>Loading county data...</p>
|
||||
<!-- ============================================================ -->
|
||||
<!-- 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}
|
||||
<!-- Breadcrumbs -->
|
||||
<nav class="breadcrumb mb-6" aria-label="Breadcrumb">
|
||||
<ul class="flex space-x-2 text-sm">
|
||||
<li><a href="/" class="text-blue-500 hover:underline">Home</a></li>
|
||||
<li class="text-gray-500">/</li>
|
||||
<li><a href="/{stateSlug}" class="text-blue-500 hover:underline">{getStateName(countyData.state)}</a></li>
|
||||
<li class="text-gray-500">/</li>
|
||||
<li class="text-gray-700 font-medium">{countyData.name}</li>
|
||||
</ul>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- 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>
|
||||
|
||||
<div class="text-center py-10">
|
||||
<h1 class="text-3xl font-bold">{countyData.name}</h1>
|
||||
<a href="/{stateSlug}" class="btn btn-primary mt-6">Back to {getStateName(countyData.state)}</a>
|
||||
</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>
|
||||
|
||||
{#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>
|
||||
<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}
|
||||
<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>
|
||||
{/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>
|
||||
{: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'}
|
||||
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}
|
||||
</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}
|
||||
<!-- ============================================================ -->
|
||||
<!-- 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>
|
||||
{/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}
|
||||
<!-- 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}
|
||||
<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 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">
|
||||
<span class="text-base font-semibold text-base-content">
|
||||
{listing.company_name}
|
||||
</span>
|
||||
</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">No phone</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 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.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">
|
||||
<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>
|
||||
|
||||
<!-- 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">Date</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each oilPrices 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">
|
||||
<span class="text-sm text-base-content/50 flex items-center gap-1">
|
||||
<Clock size={13} />
|
||||
{op.date || 'N/A'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile cards -->
|
||||
<div class="md:hidden space-y-3">
|
||||
{#each oilPrices 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">
|
||||
<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} />
|
||||
{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>
|
||||
</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}
|
||||
<div class="text-center py-10">
|
||||
<h1 class="text-3xl font-bold text-error">County Not Found</h1>
|
||||
<p class="mt-4">{error}</p>
|
||||
<a href="/" class="btn btn-primary mt-6">Go Back to Map</a>
|
||||
<!-- ============================================================ -->
|
||||
<!-- 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}
|
||||
|
||||
Reference in New Issue
Block a user