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:
@@ -77,8 +77,11 @@
|
||||
<button type="button" class="btn btn-ghost normal-case text-lg">
|
||||
{$user.username}
|
||||
</button>
|
||||
<ul class="dropdown-content menu p-2 shadow bg-base-100 rounded-box w-52 text-black">
|
||||
<ul class="dropdown-content menu p-2 shadow bg-base-100 rounded-box w-52 text-black dark:text-gray-200">
|
||||
<li><a href="/vendor">Dashboard</a></li>
|
||||
{#if $user.username === 'Anekdotin'}
|
||||
<li><a href="/admin">Admin Dashboard</a></li>
|
||||
{/if}
|
||||
<li><button type="button" on:click={logout}>Logout</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -94,7 +97,7 @@
|
||||
<div>
|
||||
<p>Copyright © {new Date().getFullYear()} - All right reserved</p>
|
||||
{#if !$user}
|
||||
<a href="/login" class="link link-primary">Dealer Login</a>
|
||||
<a href="/login" class="link link-primary">Vendor Login</a>
|
||||
{/if}
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { newEnglandStates, mapViewBox, user } from '$lib/states';
|
||||
import { goto } from '$app/navigation';
|
||||
import { browser } from '$app/environment';
|
||||
import { api } from '$lib/api';
|
||||
import type { StatsPrice } from '$lib/api';
|
||||
import { onMount } from 'svelte';
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
import {
|
||||
@@ -16,7 +18,9 @@
|
||||
} from 'lucide-svelte';
|
||||
|
||||
let hoveredState: string | null = null;
|
||||
|
||||
let mounted = false;
|
||||
let stats: Record<string, number> = {};
|
||||
|
||||
// Visible tracking for scroll-reveal sections
|
||||
let heroVisible = false;
|
||||
@@ -24,13 +28,21 @@
|
||||
let statesVisible = false;
|
||||
let valueVisible = false;
|
||||
|
||||
onMount(() => {
|
||||
onMount(async () => {
|
||||
mounted = true;
|
||||
// Stagger the reveal of sections for a smooth page load
|
||||
heroVisible = true;
|
||||
setTimeout(() => { mapVisible = true; }, 200);
|
||||
setTimeout(() => { statesVisible = true; }, 500);
|
||||
setTimeout(() => { valueVisible = true; }, 800);
|
||||
|
||||
// Fetch stats
|
||||
const result = await api.stats.getLatest();
|
||||
if (result.data) {
|
||||
result.data.forEach(s => {
|
||||
stats[s.state] = s.price;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function handleStateClick(id: string) {
|
||||
@@ -242,11 +254,17 @@
|
||||
{state.name}
|
||||
</span>
|
||||
|
||||
<!-- Arrow indicator -->
|
||||
<ChevronRight
|
||||
size={16}
|
||||
class="text-base-content/30 group-hover:text-base-content/60 group-hover:translate-x-0.5 transition-all"
|
||||
/>
|
||||
<!-- Arrow indicator or Price -->
|
||||
{#if stats[state.id]}
|
||||
<span class="text-sm font-bold {colors.text} {darkColors.text} bg-white/50 dark:bg-black/20 px-2 py-1 rounded-md">
|
||||
${stats[state.id].toFixed(2)}
|
||||
</span>
|
||||
{:else}
|
||||
<ChevronRight
|
||||
size={16}
|
||||
class="text-base-content/30 group-hover:text-base-content/60 group-hover:translate-x-0.5 transition-all"
|
||||
/>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -292,19 +310,19 @@
|
||||
</section>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- BOTTOM CTA - SUBTLE DEALER LOGIN -->
|
||||
<!-- BOTTOM CTA - SUBTLE VENDOR LOGIN -->
|
||||
<!-- ============================================================ -->
|
||||
{#if !$user}
|
||||
<section class="pb-8 text-center">
|
||||
<div class="inline-flex flex-col items-center gap-2 px-6 py-4 rounded-xl bg-base-200/40 dark:bg-base-200/20 border border-base-300/40">
|
||||
<p class="text-sm text-base-content/50">
|
||||
Are you a heating oil dealer?
|
||||
Are you a heating oil vendor?
|
||||
</p>
|
||||
<a
|
||||
href="/login"
|
||||
class="btn btn-sm btn-outline btn-primary gap-1"
|
||||
>
|
||||
Dealer Login
|
||||
Vendor Login
|
||||
<ChevronRight size={14} />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
vermontCounties,
|
||||
newHampshireCounties,
|
||||
rhodeIslandCounties,
|
||||
connecticutCounties
|
||||
connecticutCounties,
|
||||
countyMapViewBox
|
||||
} from '$lib/states';
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
@@ -32,6 +33,7 @@
|
||||
let loading = false;
|
||||
let error: string | null = null;
|
||||
let hoveredCounty: string | null = null;
|
||||
let statePrice: number | null = null;
|
||||
|
||||
// Staggered section reveal
|
||||
let headerVisible = false;
|
||||
@@ -123,6 +125,13 @@
|
||||
*/
|
||||
$: accent = stateAccent[stateSlug] ?? { badge: 'text-primary', badgeDark: '' };
|
||||
|
||||
/**
|
||||
* CRIT-013: Per-state viewBox for county maps.
|
||||
* Tighter viewBox = bigger map on mobile. Falls back to default for states without a custom one.
|
||||
*/
|
||||
// Old hardcoded viewBox for all states: "0 0 1000 600"
|
||||
$: svgViewBox = countyMapViewBox[stateSlug] ?? "0 0 1000 600";
|
||||
|
||||
onMount(async () => {
|
||||
stateData = newEnglandStates.find((s: NewEnglandState) => s.id === stateSlug);
|
||||
if (stateData) {
|
||||
@@ -142,6 +151,16 @@
|
||||
|
||||
// Load map county data
|
||||
stateCounties = getStateCounties(stateSlug);
|
||||
|
||||
// Fetch stats
|
||||
api.stats.getLatest().then((result) => {
|
||||
if (result.data) {
|
||||
const stat = result.data.find((s) => s.state === stateSlug);
|
||||
if (stat) {
|
||||
statePrice = stat.price;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Stagger section reveals
|
||||
@@ -204,6 +223,12 @@
|
||||
<!-- Subtitle -->
|
||||
<p class="text-lg sm:text-xl text-base-content/70 max-w-xl mx-auto leading-relaxed px-4">
|
||||
Explore heating oil prices by county
|
||||
{#if statePrice}
|
||||
<br/>
|
||||
<span class="inline-block mt-2 px-3 py-1 bg-base-200 rounded-full text-base font-semibold text-primary">
|
||||
Average Price: ${statePrice.toFixed(2)}
|
||||
</span>
|
||||
{/if}
|
||||
</p>
|
||||
</section>
|
||||
{/if}
|
||||
@@ -236,9 +261,10 @@
|
||||
<!-- THE SVG MAP - PRESERVED EXACTLY AS-IS -->
|
||||
<div class="flex justify-center">
|
||||
{#if browser && stateCounties.length > 0}
|
||||
<!-- CRIT-013: viewBox was hardcoded "0 0 1000 600" — now per-state for better mobile sizing -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 1000 600"
|
||||
viewBox={svgViewBox}
|
||||
class="w-full max-w-2xl h-auto border border-gray-300 rounded-lg shadow-md"
|
||||
aria-labelledby="countyMapTitle"
|
||||
role="img"
|
||||
|
||||
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>
|
||||
|
||||
241
src/routes/(app)/admin/+page.svelte
Executable file
241
src/routes/(app)/admin/+page.svelte
Executable file
@@ -0,0 +1,241 @@
|
||||
<script lang="ts">
|
||||
import { onMount as onMountSvelte } from "svelte";
|
||||
import { api } from "$lib/api/client";
|
||||
import { user } from "$lib/states";
|
||||
import AdminTable from "$lib/components/AdminTable.svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import type {
|
||||
User as UserType,
|
||||
Company,
|
||||
Listing,
|
||||
OilPrice,
|
||||
UpdateUserRequest,
|
||||
UpdateCompanyRequest,
|
||||
UpdateListingRequest,
|
||||
UpdateOilPriceRequest,
|
||||
} from "$lib/api/types";
|
||||
|
||||
let activeTab = "listings";
|
||||
let data: any[] = [];
|
||||
let loading = false;
|
||||
let error: string | null = null;
|
||||
|
||||
const tabs = [
|
||||
{ id: "listings", label: "Listings" },
|
||||
{ id: "companies", label: "Companies" },
|
||||
{ id: "users", label: "Users" },
|
||||
{ id: "oilPrices", label: "Oil Prices" },
|
||||
];
|
||||
|
||||
const columns: Record<string, any[]> = {
|
||||
users: [
|
||||
{ key: "id", label: "ID" },
|
||||
{
|
||||
key: "username",
|
||||
label: "Username",
|
||||
editable: true,
|
||||
type: "text",
|
||||
},
|
||||
{ key: "email", label: "Email", editable: true, type: "text" },
|
||||
{ key: "owner", label: "Owner ID", editable: true, type: "number" },
|
||||
],
|
||||
companies: [
|
||||
{ key: "id", label: "ID" },
|
||||
{ key: "name", label: "Name", editable: true, type: "text" },
|
||||
{ key: "active", label: "Active", editable: true, type: "boolean" },
|
||||
{ key: "town", label: "Town", editable: true, type: "text" },
|
||||
{ key: "state", label: "State", editable: true, type: "text" },
|
||||
{ key: "phone", label: "Phone", editable: true, type: "text" },
|
||||
{ key: "email", label: "Email", editable: true, type: "text" },
|
||||
],
|
||||
listings: [
|
||||
{ key: "id", label: "ID" },
|
||||
{
|
||||
key: "company_name",
|
||||
label: "Company",
|
||||
editable: true,
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
key: "price_per_gallon",
|
||||
label: "Price",
|
||||
editable: true,
|
||||
type: "number",
|
||||
},
|
||||
{
|
||||
key: "price_per_gallon_cash",
|
||||
label: "Cash Price",
|
||||
editable: true,
|
||||
type: "number",
|
||||
},
|
||||
{
|
||||
key: "bio_percent",
|
||||
label: "Bio %",
|
||||
editable: true,
|
||||
type: "number",
|
||||
},
|
||||
{ key: "town", label: "Town", editable: true, type: "text" },
|
||||
{ key: "url", label: "URL", editable: true, type: "text" },
|
||||
{
|
||||
key: "is_active",
|
||||
label: "Active",
|
||||
editable: true,
|
||||
type: "boolean",
|
||||
},
|
||||
],
|
||||
oilPrices: [
|
||||
{ key: "id", label: "ID" },
|
||||
{ key: "name", label: "Name", editable: true, type: "text" },
|
||||
{ key: "price", label: "Price", editable: true, type: "number" },
|
||||
{ key: "state", label: "State", editable: false, type: "text" },
|
||||
{ key: "phone", label: "Phone", editable: true, type: "text" },
|
||||
{ key: "url", label: "URL", editable: true, type: "text" },
|
||||
{ key: "date", label: "Date", editable: false, type: "text" },
|
||||
],
|
||||
};
|
||||
|
||||
onMountSvelte(async () => {
|
||||
const storedUser = api.auth.getStoredUser();
|
||||
console.log("Admin Page Check:", { storedUser, expected: "Anekdotin" });
|
||||
|
||||
// Simple client-side check, robust check is on API
|
||||
if (!storedUser || storedUser.username.trim() !== "Anekdotin") {
|
||||
console.log("Access denied. Redirecting to home.");
|
||||
goto("/");
|
||||
return;
|
||||
}
|
||||
await loadData();
|
||||
});
|
||||
|
||||
async function loadData() {
|
||||
loading = true;
|
||||
error = null;
|
||||
data = [];
|
||||
try {
|
||||
let res;
|
||||
switch (activeTab) {
|
||||
case "users":
|
||||
res = await api.admin.getUsers();
|
||||
break;
|
||||
case "companies":
|
||||
res = await api.admin.getCompanies();
|
||||
break;
|
||||
case "listings":
|
||||
res = await api.admin.getListings();
|
||||
break;
|
||||
case "oilPrices":
|
||||
res = await api.admin.getOilPrices();
|
||||
break;
|
||||
}
|
||||
if (res?.data) {
|
||||
data = res.data;
|
||||
} else if (res?.error) {
|
||||
error = res.error;
|
||||
}
|
||||
} catch (e) {
|
||||
error = "Failed to load data";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(item: any) {
|
||||
let res;
|
||||
switch (activeTab) {
|
||||
case "users":
|
||||
res = await api.admin.updateUser(
|
||||
item.id,
|
||||
item as UpdateUserRequest,
|
||||
);
|
||||
break;
|
||||
case "companies":
|
||||
res = await api.admin.updateCompany(
|
||||
item.id,
|
||||
item as UpdateCompanyRequest,
|
||||
);
|
||||
break;
|
||||
case "listings":
|
||||
res = await api.admin.updateListing(
|
||||
item.id,
|
||||
item as UpdateListingRequest,
|
||||
);
|
||||
break;
|
||||
case "oilPrices":
|
||||
res = await api.admin.updateOilPrice(
|
||||
item.id,
|
||||
item as UpdateOilPriceRequest,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if (res?.data) {
|
||||
// Update local data
|
||||
data = data.map((d) => (d.id === item.id ? res.data : d));
|
||||
} else if (res?.error) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(item: any) {
|
||||
let res;
|
||||
switch (activeTab) {
|
||||
case "users":
|
||||
res = await api.admin.deleteUser(item.id);
|
||||
break;
|
||||
case "companies":
|
||||
res = await api.admin.deleteCompany(item.id);
|
||||
break;
|
||||
case "listings":
|
||||
res = await api.admin.deleteListing(item.id);
|
||||
break;
|
||||
case "oilPrices":
|
||||
res = await api.admin.deleteOilPrice(item.id);
|
||||
break;
|
||||
}
|
||||
|
||||
if (!res?.error) {
|
||||
data = data.filter((d) => d.id !== item.id);
|
||||
} else {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
}
|
||||
|
||||
function switchTab(tab: string) {
|
||||
activeTab = tab;
|
||||
loadData();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto p-4">
|
||||
<h1 class="text-3xl font-bold mb-6">Admin Dashboard</h1>
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-error mb-4">
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="tabs tabs-boxed mb-4">
|
||||
{#each tabs as tab}
|
||||
<button
|
||||
class="tab {activeTab === tab.id ? 'tab-active' : ''}"
|
||||
on:click={() => switchTab(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex justify-center p-8">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
{:else}
|
||||
<AdminTable
|
||||
{data}
|
||||
columns={columns[activeTab]}
|
||||
onSave={handleSave}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -33,12 +33,12 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-white to-purple-50 px-4 sm:px-6 lg:px-8">
|
||||
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-white to-purple-50 dark:from-base-300 dark:via-base-200 dark:to-base-100 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-md w-full space-y-8">
|
||||
<div class="bg-white rounded-2xl shadow-xl p-8 border border-gray-100">
|
||||
<div class="bg-white dark:bg-base-100 rounded-2xl shadow-xl p-8 border border-gray-100 dark:border-base-300">
|
||||
<div class="text-center">
|
||||
<h2 class="text-3xl font-bold text-gray-900 mb-2">Welcome back</h2>
|
||||
<p class="text-gray-600">Sign in to your account</p>
|
||||
<h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">Welcome back</h2>
|
||||
<p class="text-gray-700 dark:text-gray-300">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
{#if errorMessage}
|
||||
@@ -55,7 +55,7 @@
|
||||
<form class="mt-8 space-y-6" on:submit|preventDefault={handleSubmit}>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-gray-700 mb-2">Username</label>
|
||||
<label for="username" class="block text-sm font-medium text-gray-900 dark:text-gray-200 mb-2">Username</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
@@ -69,14 +69,14 @@
|
||||
autocomplete="username"
|
||||
required
|
||||
bind:value={username}
|
||||
class="block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-oil focus:border-transparent transition-all duration-200 bg-gray-50 focus:bg-white"
|
||||
class="block w-full pl-10 pr-3 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-oil focus:border-transparent transition-all duration-200 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white focus:bg-white dark:focus:bg-gray-600"
|
||||
placeholder="Enter your username"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700 mb-2">Password</label>
|
||||
<label for="password" class="block text-sm font-medium text-gray-900 dark:text-gray-200 mb-2">Password</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
@@ -90,7 +90,7 @@
|
||||
autocomplete="current-password"
|
||||
required
|
||||
bind:value={password}
|
||||
class="block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-oil focus:border-transparent transition-all duration-200 bg-gray-50 focus:bg-white"
|
||||
class="block w-full pl-10 pr-3 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-oil focus:border-transparent transition-all duration-200 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white focus:bg-white dark:focus:bg-gray-600"
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
</div>
|
||||
@@ -117,10 +117,10 @@
|
||||
</form>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<p class="text-sm text-gray-600">
|
||||
Don't have an account?
|
||||
<a href="/register" class="font-medium text-blue-600 hover:text-blue-500 transition-colors duration-200">Create one here</a>
|
||||
</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-300">
|
||||
Don't have an account?
|
||||
<a href="/register" class="font-medium text-blue-600 hover:text-blue-500 transition-colors duration-200">Create one here</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -38,12 +38,12 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-green-50 via-white to-blue-50 px-4 sm:px-6 lg:px-8">
|
||||
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-green-50 via-white to-blue-50 dark:from-base-300 dark:via-base-200 dark:to-base-100 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-md w-full space-y-8">
|
||||
<div class="bg-white rounded-2xl shadow-xl p-8 border border-gray-100">
|
||||
<div class="bg-white dark:bg-base-100 rounded-2xl shadow-xl p-8 border border-gray-100 dark:border-base-300">
|
||||
<div class="text-center">
|
||||
<h2 class="text-3xl font-bold text-gray-900 mb-2">Join us today</h2>
|
||||
<p class="text-gray-600">Create your account</p>
|
||||
<h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">Join us today</h2>
|
||||
<p class="text-gray-600 dark:text-gray-300">Create your account</p>
|
||||
</div>
|
||||
|
||||
{#if errorMessage}
|
||||
@@ -60,7 +60,7 @@
|
||||
<form class="mt-8 space-y-6" on:submit|preventDefault={handleSubmit}>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="email-address" class="block text-sm font-medium text-gray-700 mb-2">Email address</label>
|
||||
<label for="email-address" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-2">Email address</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
@@ -75,14 +75,14 @@
|
||||
autocomplete="email"
|
||||
required
|
||||
bind:value={email}
|
||||
class="block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-oil focus:border-transparent transition-all duration-200 bg-gray-50 focus:bg-white"
|
||||
class="block w-full pl-10 pr-3 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-oil focus:border-transparent transition-all duration-200 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white focus:bg-white dark:focus:bg-gray-600"
|
||||
placeholder="Enter your email"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-gray-700 mb-2">Username</label>
|
||||
<label for="username" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-2">Username</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
@@ -96,14 +96,14 @@
|
||||
autocomplete="username"
|
||||
required
|
||||
bind:value={username}
|
||||
class="block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-oil focus:border-transparent transition-all duration-200 bg-gray-50 focus:bg-white"
|
||||
class="block w-full pl-10 pr-3 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-oil focus:border-transparent transition-all duration-200 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white focus:bg-white dark:focus:bg-gray-600"
|
||||
placeholder="Choose a username"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700 mb-2">Password</label>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-2">Password</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
@@ -117,14 +117,14 @@
|
||||
autocomplete="new-password"
|
||||
required
|
||||
bind:value={password}
|
||||
class="block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-oil focus:border-transparent transition-all duration-200 bg-gray-50 focus:bg-white"
|
||||
class="block w-full pl-10 pr-3 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-oil focus:border-transparent transition-all duration-200 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white focus:bg-white dark:focus:bg-gray-600"
|
||||
placeholder="Create a password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="confirm-password" class="block text-sm font-medium text-gray-700 mb-2">Confirm Password</label>
|
||||
<label for="confirm-password" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-2">Confirm Password</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
@@ -138,7 +138,7 @@
|
||||
autocomplete="new-password"
|
||||
required
|
||||
bind:value={confirmPassword}
|
||||
class="block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-oil focus:border-transparent transition-all duration-200 bg-gray-50 focus:bg-white"
|
||||
class="block w-full pl-10 pr-3 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-oil focus:border-transparent transition-all duration-200 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white focus:bg-white dark:focus:bg-gray-600"
|
||||
placeholder="Confirm your password"
|
||||
/>
|
||||
</div>
|
||||
@@ -165,7 +165,7 @@
|
||||
</form>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<p class="text-sm text-gray-600">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Already have an account?
|
||||
<a href="/login" class="font-medium text-green-600 hover:text-green-500 transition-colors duration-200">Sign in here</a>
|
||||
</p>
|
||||
|
||||
0
src/routes/(app)/vendor/+layout.svelte
vendored
Normal file → Executable file
0
src/routes/(app)/vendor/+layout.svelte
vendored
Normal file → Executable file
4
src/routes/(app)/vendor/+page.svelte
vendored
Normal file → Executable file
4
src/routes/(app)/vendor/+page.svelte
vendored
Normal file → Executable file
@@ -294,7 +294,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each listings as listing}
|
||||
{#each listings.filter(l => l.phone) as listing}
|
||||
<tr>
|
||||
<td>{listing.company_name}</td>
|
||||
<td>
|
||||
@@ -427,7 +427,7 @@
|
||||
<span class="badge badge-neutral">No</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>{listing.phone || 'N/A'}</td>
|
||||
<td>{listing.phone || ''}</td>
|
||||
<td>
|
||||
{#if listing.is_active}
|
||||
<span class="badge badge-success">Active</span>
|
||||
|
||||
21
src/routes/(app)/vendor/listing/+page.svelte
vendored
Normal file → Executable file
21
src/routes/(app)/vendor/listing/+page.svelte
vendored
Normal file → Executable file
@@ -15,7 +15,8 @@
|
||||
phone: '',
|
||||
state: '',
|
||||
countyId: 0,
|
||||
town: ''
|
||||
town: '',
|
||||
url: ''
|
||||
};
|
||||
|
||||
// Active status
|
||||
@@ -143,7 +144,9 @@
|
||||
phone: formData.phone,
|
||||
online_ordering: onlineOrdering,
|
||||
county_id: formData.countyId,
|
||||
town: formData.town.trim() || null
|
||||
|
||||
town: formData.town.trim() || null,
|
||||
url: formData.url.trim() || null
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
@@ -239,6 +242,20 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Company Website -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="url">
|
||||
<span class="label-text">Company Website (Optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="url"
|
||||
type="url"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={formData.url}
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Pricing Section -->
|
||||
<div class="bg-base-200 p-4 rounded-lg">
|
||||
<h3 class="text-lg font-semibold mb-3">Pricing Information</h3>
|
||||
|
||||
21
src/routes/(app)/vendor/listing/[id]/+page.svelte
vendored
Normal file → Executable file
21
src/routes/(app)/vendor/listing/[id]/+page.svelte
vendored
Normal file → Executable file
@@ -21,7 +21,8 @@
|
||||
phone: '',
|
||||
state: '',
|
||||
countyId: 0,
|
||||
town: ''
|
||||
town: '',
|
||||
url: ''
|
||||
};
|
||||
|
||||
// Active status
|
||||
@@ -78,6 +79,7 @@
|
||||
onlineOrdering = listing.online_ordering;
|
||||
formData.countyId = listing.county_id;
|
||||
formData.town = listing.town || '';
|
||||
formData.url = listing.url || '';
|
||||
|
||||
// Load the state for this county
|
||||
await loadStateForCounty(listing.county_id);
|
||||
@@ -200,7 +202,8 @@
|
||||
phone: formData.phone,
|
||||
online_ordering: onlineOrdering,
|
||||
county_id: formData.countyId,
|
||||
town: formData.town.trim() || null
|
||||
town: formData.town.trim() || null,
|
||||
url: formData.url.trim() || null
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
@@ -305,6 +308,20 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Company Website -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="url">
|
||||
<span class="label-text">Company Website (Optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="url"
|
||||
type="url"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={formData.url}
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Pricing Section -->
|
||||
<div class="bg-base-200 p-4 rounded-lg">
|
||||
<h3 class="text-lg font-semibold mb-3">Pricing Information</h3>
|
||||
|
||||
0
src/routes/(app)/vendor/profile/+page.svelte
vendored
Normal file → Executable file
0
src/routes/(app)/vendor/profile/+page.svelte
vendored
Normal file → Executable file
Reference in New Issue
Block a user