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:
206
src/routes/(app)/[stateSlug]/[countySlug]/+page.svelte
Normal file → Executable file
206
src/routes/(app)/[stateSlug]/[countySlug]/+page.svelte
Normal file → Executable 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>
|
||||
|
||||
Reference in New Issue
Block a user