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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
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>

View 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>

View File

@@ -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>

View File

@@ -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
View File

4
src/routes/(app)/vendor/+page.svelte vendored Normal file → Executable file
View 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
View 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
View 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
View File