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>
This commit is contained in:
2026-03-06 11:34:31 -05:00
parent d60c816002
commit 7ac2c7c59e
26 changed files with 808 additions and 86 deletions

206
src/routes/(app)/[stateSlug]/[countySlug]/+page.svelte Normal file → Executable file
View File

@@ -33,6 +33,10 @@
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;
@@ -76,7 +80,8 @@
}
// Oil prices failure is non-critical - just show empty
oilPrices = oilPricesResult.data || [];
oilPrices = (oilPricesResult.data || []).filter(p => p.price !== 0);
sortMarketPrices(); // Sort immediately after fetching
listingsLoading = false;
}
@@ -118,6 +123,58 @@
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;
@@ -154,6 +211,16 @@
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 -->
@@ -288,7 +355,7 @@
<!-- ============================================================ -->
<!-- PREMIUM DEALERS SECTION -->
<!-- ============================================================ -->
{#if premiumVisible}
{#if premiumVisible && listings.length > 0}
<section class="px-2 mt-4" in:fly={{ y: 20, duration: 400 }}>
<div class="price-section">
<!-- Section header -->
@@ -445,7 +512,7 @@
</tr>
</thead>
<tbody>
{#each listings as listing, i}
{#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' : ''}
@@ -453,9 +520,17 @@
>
<!-- Company name -->
<td class="px-4 py-4">
<span class="text-base font-semibold text-base-content">
{listing.company_name}
</span>
<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 -->
@@ -511,7 +586,7 @@
{listing.phone}
</a>
{:else}
<span class="text-sm text-base-content/40">No phone</span>
<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} />
@@ -569,7 +644,7 @@
<!-- MOBILE CARDS -->
<!-- ======================== -->
<div class="lg:hidden space-y-3">
{#each listings as listing, i}
{#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">
@@ -577,6 +652,12 @@
<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} />
@@ -667,16 +748,33 @@
<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 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>
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>
<!-- 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>
@@ -693,12 +791,18 @@
<span class="sort-header">Price / Gal</span>
</th>
<th class="text-left px-4 py-3">
<span class="sort-header">Date</span>
<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 as op, i}
{#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' : ''}
@@ -714,10 +818,38 @@
{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} />
{op.date || 'N/A'}
{formatScrapeDate(op.scrapetimestamp) || op.date || 'N/A'}
</span>
</td>
</tr>
@@ -729,22 +861,50 @@
<!-- Mobile cards -->
<div class="md:hidden space-y-3">
{#each oilPrices as op, i}
{#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">
<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} />
{op.date || 'N/A'}
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>