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

6
package-lock.json generated
View File

@@ -1255,9 +1255,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001664", "version": "1.0.30001769",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001664.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz",
"integrity": "sha512-AmE7k4dXiNKQipgn7a2xg558IRqPN3jMQY/rOsbxDhrd0tyChwbITBfiwtnqz8bi2M5mIWbxAYBvk7W7QBUS2g==", "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {

90
src/lib/api/client.ts Normal file → Executable file
View File

@@ -14,7 +14,10 @@ import type {
UpdateListingRequest, UpdateListingRequest,
OilPrice, OilPrice,
County, County,
ServiceCategory ServiceCategory,
StatsPrice,
UpdateUserRequest,
UpdateOilPriceRequest
} from './types'; } from './types';
/** /**
@@ -297,6 +300,18 @@ export const stateApi = {
} }
}; };
/**
* Stats API methods
*/
export const statsApi = {
/**
* Get latest market stats
*/
async getLatest(): Promise<ApiResponse<StatsPrice[]>> {
return request<StatsPrice[]>('/stats');
}
};
/** /**
* Categories API methods * Categories API methods
*/ */
@@ -309,6 +324,75 @@ export const categoriesApi = {
} }
}; };
/**
* Admin API methods
*/
export const adminApi = {
// Users
async getUsers(): Promise<ApiResponse<User[]>> {
return request<User[]>('/admin/users', {}, true);
},
async updateUser(id: number, data: UpdateUserRequest): Promise<ApiResponse<User>> {
return request<User>(`/admin/users/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}, true);
},
async deleteUser(id: number): Promise<ApiResponse<null>> {
return request<null>(`/admin/users/${id}`, {
method: 'DELETE',
}, true);
},
// Companies
async getCompanies(): Promise<ApiResponse<Company[]>> {
return request<Company[]>('/admin/companies', {}, true);
},
async updateCompany(id: number, data: UpdateCompanyRequest): Promise<ApiResponse<Company>> {
return request<Company>(`/admin/companies/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}, true);
},
async deleteCompany(id: number): Promise<ApiResponse<null>> {
return request<null>(`/admin/companies/${id}`, {
method: 'DELETE',
}, true);
},
// Listings
async getListings(): Promise<ApiResponse<Listing[]>> {
return request<Listing[]>('/admin/listings', {}, true);
},
async updateListing(id: number, data: UpdateListingRequest): Promise<ApiResponse<Listing>> {
return request<Listing>(`/admin/listings/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}, true);
},
async deleteListing(id: number): Promise<ApiResponse<null>> {
return request<null>(`/admin/listings/${id}`, {
method: 'DELETE',
}, true);
},
// Oil Prices
async getOilPrices(): Promise<ApiResponse<OilPrice[]>> {
return request<OilPrice[]>('/admin/oil-prices', {}, true);
},
async updateOilPrice(id: number, data: UpdateOilPriceRequest): Promise<ApiResponse<OilPrice>> {
return request<OilPrice>(`/admin/oil-prices/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}, true);
},
async deleteOilPrice(id: number): Promise<ApiResponse<null>> {
return request<null>(`/admin/oil-prices/${id}`, {
method: 'DELETE',
}, true);
}
};
/** /**
* Unified API object for convenient imports * Unified API object for convenient imports
*/ */
@@ -319,5 +403,7 @@ export const api = {
listings: listingsApi, listings: listingsApi,
oilPrices: oilPricesApi, oilPrices: oilPricesApi,
state: stateApi, state: stateApi,
categories: categoriesApi categories: categoriesApi,
stats: statsApi,
admin: adminApi
}; };

0
src/lib/api/index.ts Normal file → Executable file
View File

24
src/lib/api/types.ts Normal file → Executable file
View File

@@ -77,6 +77,7 @@ export interface Listing {
online_ordering: string; online_ordering: string;
county_id: number; county_id: number;
town: string | null; town: string | null;
url: string | null;
user_id: number; user_id: number;
created_at?: string; created_at?: string;
last_edited?: string; last_edited?: string;
@@ -95,6 +96,7 @@ export interface CreateListingRequest {
online_ordering?: string; online_ordering?: string;
county_id: number; county_id: number;
town?: string | null; town?: string | null;
url?: string | null;
} }
export type UpdateListingRequest = Partial<CreateListingRequest>; export type UpdateListingRequest = Partial<CreateListingRequest>;
@@ -109,6 +111,8 @@ export interface OilPrice {
date: string | null; date: string | null;
scrapetimestamp: string | null; scrapetimestamp: string | null;
county_id: number | null; county_id: number | null;
phone: string | null;
url: string | null;
} }
// State/County Types // State/County Types
@@ -126,3 +130,23 @@ export interface ServiceCategory {
clicks_total: number; clicks_total: number;
total_companies: number; total_companies: number;
} }
export interface StatsPrice {
id: number;
state: string;
price: number;
created_at?: string;
}
export interface UpdateUserRequest {
username?: string;
email?: string;
owner?: number | null;
}
export interface UpdateOilPriceRequest {
price?: number;
name?: string;
url?: string;
phone?: string;
}

View File

@@ -0,0 +1,100 @@
<script lang="ts">
export let data: any[] = [];
export let columns: { key: string; label: string; type?: 'text' | 'number' | 'boolean' | 'date'; editable?: boolean }[] = [];
export let onSave: (item: any) => Promise<void>;
export let onDelete: (item: any) => Promise<void>;
let editingId: number | null = null;
let editValues: any = {};
let isSaving = false;
let isDeleting = false;
function startEdit(item: any) {
editingId = item.id;
editValues = { ...item };
}
async function save() {
if (!editingId) return;
isSaving = true;
try {
await onSave(editValues);
editingId = null;
editValues = {};
} catch (e) {
alert('Failed to save: ' + e);
} finally {
isSaving = false;
}
}
function cancel() {
editingId = null;
editValues = {};
}
async function handleDelete(item: any) {
if (confirm('Are you sure you want to delete this item?')) {
isDeleting = true;
try {
await onDelete(item);
} catch (e) {
alert('Failed to delete: ' + e);
} finally {
isDeleting = false;
}
}
}
</script>
<div class="overflow-x-auto">
<table class="table table-xs md:table-sm table-pin-rows">
<thead>
<tr>
{#each columns as col}
<th>{col.label}</th>
{/each}
<th>Actions</th>
</tr>
</thead>
<tbody>
{#each data as item (item.id)}
{@const isEditing = editingId === item.id}
<tr class="hover">
{#each columns as col}
<td>
{#if isEditing && col.editable}
{#if col.type === 'boolean'}
<input type="checkbox" class="checkbox checkbox-sm" bind:checked={editValues[col.key]} />
{:else if col.type === 'number'}
<input type="number" class="input input-bordered input-sm w-full" bind:value={editValues[col.key]} />
{:else}
<input type="text" class="input input-bordered input-sm w-full" bind:value={editValues[col.key]} />
{/if}
{:else}
{#if col.type === 'boolean'}
<input type="checkbox" class="checkbox checkbox-xs" checked={item[col.key]} disabled />
{:else}
<div class="truncate max-w-[200px]" title={item[col.key]}>
{item[col.key] ?? ''}
</div>
{/if}
{/if}
</td>
{/each}
<td class="flex gap-2">
{#if isEditing}
<button class="btn btn-xs btn-success" on:click={save} disabled={isSaving}>
{#if isSaving}Saving...{:else}Save{/if}
</button>
<button class="btn btn-xs btn-ghost" on:click={cancel} disabled={isSaving}>Cancel</button>
{:else}
<button class="btn btn-xs btn-primary" on:click={() => startEdit(item)}>Edit</button>
<button class="btn btn-xs btn-error" on:click={() => handleDelete(item)} disabled={isDeleting}>Delete</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>

View File

@@ -1,11 +1,11 @@
// src/lib/states.ts // src/lib/states.ts
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import { massachusettsCounties } from './states/massachusetts'; import { massachusettsCounties, mapViewBox as maMapViewBox } from './states/massachusetts';
import { maineCounties } from './states/maine'; import { maineCounties, mapViewBox as maineMapViewBox } from './states/maine';
import { vermontCounties } from './states/vermont'; import { vermontCounties, mapViewBox as vermontMapViewBox } from './states/vermont';
import { newHampshireCounties } from './states/newhampshire'; import { newHampshireCounties, mapViewBox as nhMapViewBox } from './states/newhampshire';
import { rhodeIslandCounties } from './states/rhodeisland'; import { rhodeIslandCounties, mapViewBox as riMapViewBox } from './states/rhodeisland';
import { connecticutCounties } from './states/connecticut'; import { connecticutCounties, mapViewBox as ctMapViewBox } from './states/connecticut';
export interface NewEnglandState { export interface NewEnglandState {
id: string; id: string;
@@ -96,6 +96,17 @@ export const newEnglandStates: NewEnglandState[] = [
export const mapViewBox: string = "890 20 95 155"; export const mapViewBox: string = "890 20 95 155";
// CRIT-013: Per-state county map viewBoxes (tighter crop = bigger on mobile)
// States not listed here use the default "0 0 1000 600"
export const countyMapViewBox: Record<string, string> = {
VT: vermontMapViewBox,
NH: nhMapViewBox,
ME: maineMapViewBox,
RI: riMapViewBox,
CT: ctMapViewBox,
MA: maMapViewBox,
};
/** /**
* SVG transform string to apply to the group of states to straighten the map. * SVG transform string to apply to the group of states to straighten the map.
* Format: "rotate(angle cx cy)" * Format: "rotate(angle cx cy)"

View File

@@ -15,7 +15,7 @@ export const connecticutCounties: NewEnglandState[] = [
name: 'Fairfield County', name: 'Fairfield County',
slug: 'fairfield', slug: 'fairfield',
image: '/images/counties/ct/fairfield.png', image: '/images/counties/ct/fairfield.png',
pathD: "M 776.1 12.71 793.94 13.63 902.92 15.76 903.51 24.54 904.53 69.52 904.84 83.14 905.65 118.93 905.65 119.47 906.23 137.24 906.24 138.18 907.54 184.57 907.54 184.67 907.63 232.28 838 235.22 792.97 225.41 775.53 221.43 774.63 221.3 768.46 219.95 754.55 217.86 752.8 222.37 740.56 211.87 718.98 191.09 718.31 190.51 753.32 168.36 753.08 168.18 749.85 122.43 727.49 122.27 727.8 52.78 755.28 52.13 766.69 52.28 767.07 52.32 777.33 52.33 776.1 12.71 Z", pathD: "M 180.79 217.29 181.4 217.68 181.92 217.72 183.45 217.38 183.77 217.38 185.22 217.77 212.76 315.89 258.98 326.35 261 328.12 266.85 329.3 267.86 328.51 311.09 355.6 320.69 363.59 319.39 368.64 321.36 371.77 325.53 376.32 330.62 376.18 336.38 385.23 341.22 394.48 349.76 405.12 360.2 412.37 362 415.04 365.24 416.66 365.79 418.85 366.75 420.02 369.91 422.6 404.72 459.89 404.23 460.37 399.31 466.41 396.92 470.55 395.27 473.4 392.78 476.04 392.56 476.27 392.16 476.7 389.97 476.81 389.96 476.73 389.95 476.62 389.95 476.55 389.93 476.36 389.85 475.52 388.96 475.08 388.02 474.94 379.27 473.7 377.14 473.4 375.34 474.37 373.48 475.36 365.8 481.8 365.03 482.45 360.08 487.96 359.75 488.32 359.69 488.39 354.56 494.1 354.08 494.64 354.07 494.64 353.03 496.57 352.03 501.62 352.2 503.13 352.87 504.96 351.74 506.6 343.66 508.83 329.31 502.26 326.91 501.16 326.65 498.69 323.67 497.71 313.21 502.52 302.42 511.17 299.58 510.4 294.15 520.22 288.09 525.19 277.83 519.39 273.79 520.62 267.27 525.86 259.36 529.38 241.85 532.72 233.5 541.83 230.13 542.61 224.45 549.88 215.45 559.12 209.01 558.9 201.5 562.11 197.66 570.67 190.94 563.88 181.03 569.14 181.08 574.29 178.6 579.98 175.99 581.6 175.03 578.52 175.44 574.7 174.35 573.08 173.27 572.94 166.48 577.17 162.07 581.37 159.67 584.66 158.66 589.85 152.82 590.24 152.39 584.86 147.88 581.82 144.41 582.35 127.79 589.53 124.55 593.4 121.96 599 123.08 584.22 122.78 583.6 116.43 573.96 112.44 567.43 109.41 562.47 106.42 557.67 92.33 534.57 129.39 511.79 132.55 509.88 140.01 505.36 181.99 479.98 184.15 478.65 195.41 471.97 166.7 425.72 169.32 385.97 169.78 380.14 172.29 348.11 172.38 346.99 172.59 344.07 172.97 338.33 173 337.36 173.04 336.14 173.05 335.87 173.09 335.06 173.68 324.54 173.72 324.26 173.72 323.97 173.77 323.33 173.81 322.82 173.85 322.41 175.49 295.73 179.11 245.56 180.79 217.29 Z",
fill: 'fill-green-500', fill: 'fill-green-500',
hoverFill: 'fill-green-700' hoverFill: 'fill-green-700'
}, },
@@ -78,13 +78,16 @@ export const connecticutCounties: NewEnglandState[] = [
name: 'Windham County', name: 'Windham County',
slug: 'windham', slug: 'windham',
image: '/images/counties/ct/windham.png', image: '/images/counties/ct/windham.png',
pathD: "M 180.79 217.29 181.4 217.68 181.92 217.72 183.45 217.38 183.77 217.38 185.22 217.77 212.76 315.89 258.98 326.35 261 328.12 266.85 329.3 267.86 328.51 311.09 355.6 320.69 363.59 319.39 368.64 321.36 371.77 325.53 376.32 330.62 376.18 336.38 385.23 341.22 394.48 349.76 405.12 360.2 412.37 362 415.04 365.24 416.66 365.79 418.85 366.75 420.02 369.91 422.6 404.72 459.89 404.23 460.37 399.31 466.41 396.92 470.55 395.27 473.4 392.78 476.04 392.56 476.27 392.16 476.7 389.97 476.81 389.96 476.73 389.95 476.62 389.95 476.55 389.93 476.36 389.85 475.52 388.96 475.08 388.02 474.94 379.27 473.7 377.14 473.4 375.34 474.37 373.48 475.36 365.8 481.8 365.03 482.45 360.08 487.96 359.75 488.32 359.69 488.39 354.56 494.1 354.08 494.64 354.07 494.64 353.03 496.57 352.03 501.62 352.2 503.13 352.87 504.96 351.74 506.6 343.66 508.83 329.31 502.26 326.91 501.16 326.65 498.69 323.67 497.71 313.21 502.52 302.42 511.17 299.58 510.4 294.15 520.22 288.09 525.19 277.83 519.39 273.79 520.62 267.27 525.86 259.36 529.38 241.85 532.72 233.5 541.83 230.13 542.61 224.45 549.88 215.45 559.12 209.01 558.9 201.5 562.11 197.66 570.67 190.94 563.88 181.03 569.14 181.08 574.29 178.6 579.98 175.99 581.6 175.03 578.52 175.44 574.7 174.35 573.08 173.27 572.94 166.48 577.17 162.07 581.37 159.67 584.66 158.66 589.85 152.82 590.24 152.39 584.86 147.88 581.82 144.41 582.35 127.79 589.53 124.55 593.4 121.96 599 123.08 584.22 122.78 583.6 116.43 573.96 112.44 567.43 109.41 562.47 106.42 557.67 92.33 534.57 129.39 511.79 132.55 509.88 140.01 505.36 181.99 479.98 184.15 478.65 195.41 471.97 166.7 425.72 169.32 385.97 169.78 380.14 172.29 348.11 172.38 346.99 172.59 344.07 172.97 338.33 173 337.36 173.04 336.14 173.05 335.87 173.09 335.06 173.68 324.54 173.72 324.26 173.72 323.97 173.77 323.33 173.81 322.82 173.85 322.41 175.49 295.73 179.11 245.56 180.79 217.29 Z", pathD: "M 776.1 12.71 793.94 13.63 902.92 15.76 903.51 24.54 904.53 69.52 904.84 83.14 905.65 118.93 905.65 119.47 906.23 137.24 906.24 138.18 907.54 184.57 907.54 184.67 907.63 232.28 838 235.22 792.97 225.41 775.53 221.43 774.63 221.3 768.46 219.95 754.55 217.86 752.8 222.37 740.56 211.87 718.98 191.09 718.31 190.51 753.32 168.36 753.08 168.18 749.85 122.43 727.49 122.27 727.8 52.78 755.28 52.13 766.69 52.28 767.07 52.32 777.33 52.33 776.1 12.71 Z",
fill: 'fill-green-500', fill: 'fill-green-500',
hoverFill: 'fill-green-700' hoverFill: 'fill-green-700'
} }
]; ];
export const mapViewBox: string = "890 20 95 155"; // CRIT-013: Old viewBox was a copy-paste from the home page map and not used for county view
// export const mapViewBox: string = "890 20 95 155";
// New viewBox: tight crop around CT county paths (x:92-908, y:1-599) + 25px padding
export const mapViewBox: string = "65 -25 870 650";
/** /**
* SVG transform string to apply to the group of states to straighten the map. * SVG transform string to apply to the group of states to straighten the map.

View File

@@ -157,6 +157,9 @@ export const maineCounties: NewEnglandState[] = [
} }
]; ];
export const mapViewBox: string = "0 0 1000 600"; // CRIT-013: Old viewBox used full 1000x600 with lots of dead space
// export const mapViewBox: string = "0 0 1000 600";
// New viewBox: tight crop around Maine county paths (x:313-687, y:18-582) + 25px padding
export const mapViewBox: string = "285 -10 430 620";
export const mapTransform: string | undefined = undefined; export const mapTransform: string | undefined = undefined;

View File

@@ -138,3 +138,7 @@ export const massachusettsCounties: NewEnglandState[] = [
} }
]; ];
// CRIT-013: MA paths already fill most of 1000x600 (x:18-982, y:1-599), so gain is small (~2%)
// but applied for consistency across all 6 states
export const mapViewBox: string = "8 -10 985 620";

5
src/lib/states/newhampshire.ts Normal file → Executable file
View File

@@ -102,7 +102,10 @@ export const newHampshireCounties: NewEnglandState[] = [
} }
]; ];
export const mapViewBox: string = "890 20 95 155"; // CRIT-013: Old viewBox was a copy-paste from the home page map and not used for county view
// export const mapViewBox: string = "890 20 95 155";
// New viewBox: tight crop around NH county paths (x:347-653, y:1-599) + 25px padding
export const mapViewBox: string = "320 -25 360 650";
/** /**
* SVG transform string to apply to the group of states to straighten the map. * SVG transform string to apply to the group of states to straighten the map.

View File

@@ -58,7 +58,10 @@ export const rhodeIslandCounties: NewEnglandState[] = [
]; ];
export const mapViewBox: string = "890 20 95 155"; // CRIT-013: Old viewBox was a copy-paste from the home page map and not used for county view
// export const mapViewBox: string = "890 20 95 155";
// New viewBox: tight crop around RI county paths (x:310-690, y:1-599) + 25px padding
export const mapViewBox: string = "285 -25 430 650";
/** /**
* SVG transform string to apply to the group of states to straighten the map. * SVG transform string to apply to the group of states to straighten the map.

View File

@@ -138,7 +138,10 @@ export const vermontCounties: NewEnglandState[] = [
} }
]; ];
export const mapViewBox: string = "890 20 95 155"; // CRIT-013: Old viewBox was a copy-paste from the home page map and not used for county view
// export const mapViewBox: string = "890 20 95 155";
// New viewBox: tight crop around Vermont county paths (x:314-686, y:1-599) + 25px padding
export const mapViewBox: string = "290 -25 420 650";
/** /**
* SVG transform string to apply to the group of states to straighten the map. * SVG transform string to apply to the group of states to straighten the map.

View File

@@ -77,8 +77,11 @@
<button type="button" class="btn btn-ghost normal-case text-lg"> <button type="button" class="btn btn-ghost normal-case text-lg">
{$user.username} {$user.username}
</button> </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> <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> <li><button type="button" on:click={logout}>Logout</button></li>
</ul> </ul>
</div> </div>
@@ -94,7 +97,7 @@
<div> <div>
<p>Copyright © {new Date().getFullYear()} - All right reserved</p> <p>Copyright © {new Date().getFullYear()} - All right reserved</p>
{#if !$user} {#if !$user}
<a href="/login" class="link link-primary">Dealer Login</a> <a href="/login" class="link link-primary">Vendor Login</a>
{/if} {/if}
</div> </div>
</footer> </footer>

View File

@@ -3,6 +3,8 @@
import { newEnglandStates, mapViewBox, user } from '$lib/states'; import { newEnglandStates, mapViewBox, user } from '$lib/states';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { api } from '$lib/api';
import type { StatsPrice } from '$lib/api';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { fade, fly } from 'svelte/transition'; import { fade, fly } from 'svelte/transition';
import { import {
@@ -16,7 +18,9 @@
} from 'lucide-svelte'; } from 'lucide-svelte';
let hoveredState: string | null = null; let hoveredState: string | null = null;
let mounted = false; let mounted = false;
let stats: Record<string, number> = {};
// Visible tracking for scroll-reveal sections // Visible tracking for scroll-reveal sections
let heroVisible = false; let heroVisible = false;
@@ -24,13 +28,21 @@
let statesVisible = false; let statesVisible = false;
let valueVisible = false; let valueVisible = false;
onMount(() => { onMount(async () => {
mounted = true; mounted = true;
// Stagger the reveal of sections for a smooth page load // Stagger the reveal of sections for a smooth page load
heroVisible = true; heroVisible = true;
setTimeout(() => { mapVisible = true; }, 200); setTimeout(() => { mapVisible = true; }, 200);
setTimeout(() => { statesVisible = true; }, 500); setTimeout(() => { statesVisible = true; }, 500);
setTimeout(() => { valueVisible = true; }, 800); 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) { function handleStateClick(id: string) {
@@ -242,11 +254,17 @@
{state.name} {state.name}
</span> </span>
<!-- Arrow indicator --> <!-- Arrow indicator or Price -->
<ChevronRight {#if stats[state.id]}
size={16} <span class="text-sm font-bold {colors.text} {darkColors.text} bg-white/50 dark:bg-black/20 px-2 py-1 rounded-md">
class="text-base-content/30 group-hover:text-base-content/60 group-hover:translate-x-0.5 transition-all" ${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> </a>
{/each} {/each}
</div> </div>
@@ -292,19 +310,19 @@
</section> </section>
<!-- ============================================================ --> <!-- ============================================================ -->
<!-- BOTTOM CTA - SUBTLE DEALER LOGIN --> <!-- BOTTOM CTA - SUBTLE VENDOR LOGIN -->
<!-- ============================================================ --> <!-- ============================================================ -->
{#if !$user} {#if !$user}
<section class="pb-8 text-center"> <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"> <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"> <p class="text-sm text-base-content/50">
Are you a heating oil dealer? Are you a heating oil vendor?
</p> </p>
<a <a
href="/login" href="/login"
class="btn btn-sm btn-outline btn-primary gap-1" class="btn btn-sm btn-outline btn-primary gap-1"
> >
Dealer Login Vendor Login
<ChevronRight size={14} /> <ChevronRight size={14} />
</a> </a>
</div> </div>

View File

@@ -9,7 +9,8 @@
vermontCounties, vermontCounties,
newHampshireCounties, newHampshireCounties,
rhodeIslandCounties, rhodeIslandCounties,
connecticutCounties connecticutCounties,
countyMapViewBox
} from '$lib/states'; } from '$lib/states';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
@@ -32,6 +33,7 @@
let loading = false; let loading = false;
let error: string | null = null; let error: string | null = null;
let hoveredCounty: string | null = null; let hoveredCounty: string | null = null;
let statePrice: number | null = null;
// Staggered section reveal // Staggered section reveal
let headerVisible = false; let headerVisible = false;
@@ -123,6 +125,13 @@
*/ */
$: accent = stateAccent[stateSlug] ?? { badge: 'text-primary', badgeDark: '' }; $: 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 () => { onMount(async () => {
stateData = newEnglandStates.find((s: NewEnglandState) => s.id === stateSlug); stateData = newEnglandStates.find((s: NewEnglandState) => s.id === stateSlug);
if (stateData) { if (stateData) {
@@ -142,6 +151,16 @@
// Load map county data // Load map county data
stateCounties = getStateCounties(stateSlug); 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 // Stagger section reveals
@@ -204,6 +223,12 @@
<!-- Subtitle --> <!-- Subtitle -->
<p class="text-lg sm:text-xl text-base-content/70 max-w-xl mx-auto leading-relaxed px-4"> <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 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> </p>
</section> </section>
{/if} {/if}
@@ -236,9 +261,10 @@
<!-- THE SVG MAP - PRESERVED EXACTLY AS-IS --> <!-- THE SVG MAP - PRESERVED EXACTLY AS-IS -->
<div class="flex justify-center"> <div class="flex justify-center">
{#if browser && stateCounties.length > 0} {#if browser && stateCounties.length > 0}
<!-- CRIT-013: viewBox was hardcoded "0 0 1000 600" — now per-state for better mobile sizing -->
<svg <svg
xmlns="http://www.w3.org/2000/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" class="w-full max-w-2xl h-auto border border-gray-300 rounded-lg shadow-md"
aria-labelledby="countyMapTitle" aria-labelledby="countyMapTitle"
role="img" 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 sortColumn = 'price_per_gallon';
let sortDirection = 'asc'; let sortDirection = 'asc';
// Market Prices sorting
let marketSortColumn = 'date';
let marketSortDirection = 'desc';
// Staggered section reveal // Staggered section reveal
let headerVisible = false; let headerVisible = false;
let premiumVisible = false; let premiumVisible = false;
@@ -76,7 +80,8 @@
} }
// Oil prices failure is non-critical - just show empty // 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; listingsLoading = false;
} }
@@ -118,6 +123,58 @@
sortListings(); 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 { function getStateName(stateAbbr: string): string {
const state = newEnglandStates.find(s => s.id === stateAbbr); const state = newEnglandStates.find(s => s.id === stateAbbr);
return state ? state.name : stateAbbr; return state ? state.name : stateAbbr;
@@ -154,6 +211,16 @@
if (value === 'both') return 'Phone & Online'; if (value === 'both') return 'Phone & Online';
return 'Phone Only'; 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> </script>
<!-- SEO --> <!-- SEO -->
@@ -288,7 +355,7 @@
<!-- ============================================================ --> <!-- ============================================================ -->
<!-- PREMIUM DEALERS SECTION --> <!-- PREMIUM DEALERS SECTION -->
<!-- ============================================================ --> <!-- ============================================================ -->
{#if premiumVisible} {#if premiumVisible && listings.length > 0}
<section class="px-2 mt-4" in:fly={{ y: 20, duration: 400 }}> <section class="px-2 mt-4" in:fly={{ y: 20, duration: 400 }}>
<div class="price-section"> <div class="price-section">
<!-- Section header --> <!-- Section header -->
@@ -445,7 +512,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each listings as listing, i} {#each listings.filter(l => l.phone) as listing, i}
<tr <tr
class="border-b border-base-300/50 last:border-b-0 class="border-b border-base-300/50 last:border-b-0
{i % 2 === 1 ? 'bg-base-200/30' : ''} {i % 2 === 1 ? 'bg-base-200/30' : ''}
@@ -453,9 +520,17 @@
> >
<!-- Company name --> <!-- Company name -->
<td class="px-4 py-4"> <td class="px-4 py-4">
<span class="text-base font-semibold text-base-content"> <div class="flex flex-col">
{listing.company_name} <span class="text-base font-semibold text-base-content">
</span> {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> </td>
<!-- Town --> <!-- Town -->
@@ -511,7 +586,7 @@
{listing.phone} {listing.phone}
</a> </a>
{:else} {:else}
<span class="text-sm text-base-content/40">No phone</span> <span class="text-sm text-base-content/40"></span>
{/if} {/if}
<div class="text-xs text-base-content/40 flex items-center gap-1"> <div class="text-xs text-base-content/40 flex items-center gap-1">
<Globe size={11} /> <Globe size={11} />
@@ -569,7 +644,7 @@
<!-- MOBILE CARDS --> <!-- MOBILE CARDS -->
<!-- ======================== --> <!-- ======================== -->
<div class="lg:hidden space-y-3"> <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"> <div class="listing-card stagger-{(i % 6) + 1} opacity-0 animate-fade-in-up">
<!-- Top row: Company + Price --> <!-- Top row: Company + Price -->
<div class="flex items-start justify-between gap-3 mb-3"> <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"> <h3 class="text-lg font-bold text-base-content leading-tight truncate">
{listing.company_name} {listing.company_name}
</h3> </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} {#if listing.town}
<p class="text-sm text-base-content/50 flex items-center gap-1 mt-0.5"> <p class="text-sm text-base-content/50 flex items-center gap-1 mt-0.5">
<MapPin size={13} /> <MapPin size={13} />
@@ -667,16 +748,33 @@
<section class="px-2 mt-6 sm:mt-8" in:fly={{ y: 20, duration: 400 }}> <section class="px-2 mt-6 sm:mt-8" in:fly={{ y: 20, duration: 400 }}>
<div class="price-section"> <div class="price-section">
<!-- Section header --> <!-- Section header -->
<div class="mb-4"> <div class="mb-4 flex flex-col md:flex-row md:items-center justify-between gap-4">
<h2 class="text-2xl sm:text-3xl font-bold text-base-content flex items-center gap-2"> <div>
<div class="value-icon !w-9 !h-9 sm:!w-10 sm:!h-10 flex-shrink-0"> <h2 class="text-2xl sm:text-3xl font-bold text-base-content flex items-center gap-2">
<Flame size={20} /> <div class="value-icon !w-9 !h-9 sm:!w-10 sm:!h-10 flex-shrink-0">
<Flame size={20} />
</div>
Market Prices
</h2>
<div class="info-note mt-2">
<Info size={14} class="flex-shrink-0" />
<span>Market prices collected from public sources</span>
</div> </div>
Market Prices </div>
</h2>
<div class="info-note mt-2"> <!-- Sort Dropdown -->
<Info size={14} class="flex-shrink-0" /> <div class="flex items-center gap-2">
<span>Market prices collected from public sources</span> <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>
</div> </div>
@@ -693,12 +791,18 @@
<span class="sort-header">Price / Gal</span> <span class="sort-header">Price / Gal</span>
</th> </th>
<th class="text-left px-4 py-3"> <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> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each oilPrices as op, i} {#each oilPrices.filter(op => op.phone) as op, i}
<tr <tr
class="border-b border-base-300/50 last:border-b-0 class="border-b border-base-300/50 last:border-b-0
{i % 2 === 1 ? 'bg-base-200/30' : ''} {i % 2 === 1 ? 'bg-base-200/30' : ''}
@@ -714,10 +818,38 @@
{op.price != null ? `$${op.price.toFixed(2)}` : 'N/A'} {op.price != null ? `$${op.price.toFixed(2)}` : 'N/A'}
</span> </span>
</td> </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"> <td class="px-4 py-3">
<span class="text-sm text-base-content/50 flex items-center gap-1"> <span class="text-sm text-base-content/50 flex items-center gap-1">
<Clock size={13} /> <Clock size={13} />
{op.date || 'N/A'} {formatScrapeDate(op.scrapetimestamp) || op.date || 'N/A'}
</span> </span>
</td> </td>
</tr> </tr>
@@ -729,22 +861,50 @@
<!-- Mobile cards --> <!-- Mobile cards -->
<div class="md:hidden space-y-3"> <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="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"> <div class="min-w-0 flex-1">
<span class="text-base font-semibold text-base-content block truncate"> <span class="text-base font-semibold text-base-content block truncate">
{op.name || 'Unknown'} {op.name || 'Unknown'}
</span> </span>
<span class="text-xs text-base-content/40 flex items-center gap-1 mt-0.5"> <span class="text-xs text-base-content/40 flex items-center gap-1 mt-0.5">
<Clock size={11} /> <Clock size={11} />
{op.date || 'N/A'} Last Updated: {formatScrapeDate(op.scrapetimestamp) || op.date || 'N/A'}
</span> </span>
</div> </div>
<div class="price-hero !text-2xl flex-shrink-0"> <div class="price-hero !text-2xl flex-shrink-0">
{op.price != null ? `$${op.price.toFixed(2)}` : 'N/A'} {op.price != null ? `$${op.price.toFixed(2)}` : 'N/A'}
</div> </div>
</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> </div>
{/each} {/each}
</div> </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> </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="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"> <div class="text-center">
<h2 class="text-3xl font-bold text-gray-900 mb-2">Welcome back</h2> <h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">Welcome back</h2>
<p class="text-gray-600">Sign in to your account</p> <p class="text-gray-700 dark:text-gray-300">Sign in to your account</p>
</div> </div>
{#if errorMessage} {#if errorMessage}
@@ -55,7 +55,7 @@
<form class="mt-8 space-y-6" on:submit|preventDefault={handleSubmit}> <form class="mt-8 space-y-6" on:submit|preventDefault={handleSubmit}>
<div class="space-y-4"> <div class="space-y-4">
<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-900 dark:text-gray-200 mb-2">Username</label>
<div class="relative"> <div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <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"> <svg class="h-5 w-5 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
@@ -69,14 +69,14 @@
autocomplete="username" autocomplete="username"
required required
bind:value={username} 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" placeholder="Enter your username"
/> />
</div> </div>
</div> </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="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <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"> <svg class="h-5 w-5 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
@@ -90,7 +90,7 @@
autocomplete="current-password" autocomplete="current-password"
required required
bind:value={password} 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" placeholder="Enter your password"
/> />
</div> </div>
@@ -117,10 +117,10 @@
</form> </form>
<div class="mt-6 text-center"> <div class="mt-6 text-center">
<p class="text-sm text-gray-600"> <p class="text-sm text-gray-900 dark:text-gray-300">
Don't have an account? 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> <a href="/register" class="font-medium text-blue-600 hover:text-blue-500 transition-colors duration-200">Create one here</a>
</p> </p>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -38,12 +38,12 @@
} }
</script> </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="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"> <div class="text-center">
<h2 class="text-3xl font-bold text-gray-900 mb-2">Join us today</h2> <h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">Join us today</h2>
<p class="text-gray-600">Create your account</p> <p class="text-gray-600 dark:text-gray-300">Create your account</p>
</div> </div>
{#if errorMessage} {#if errorMessage}
@@ -60,7 +60,7 @@
<form class="mt-8 space-y-6" on:submit|preventDefault={handleSubmit}> <form class="mt-8 space-y-6" on:submit|preventDefault={handleSubmit}>
<div class="space-y-4"> <div class="space-y-4">
<div> <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="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <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"> <svg class="h-5 w-5 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
@@ -75,14 +75,14 @@
autocomplete="email" autocomplete="email"
required required
bind:value={email} 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" placeholder="Enter your email"
/> />
</div> </div>
</div> </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="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <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"> <svg class="h-5 w-5 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
@@ -96,14 +96,14 @@
autocomplete="username" autocomplete="username"
required required
bind:value={username} 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" placeholder="Choose a username"
/> />
</div> </div>
</div> </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="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <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"> <svg class="h-5 w-5 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
@@ -117,14 +117,14 @@
autocomplete="new-password" autocomplete="new-password"
required required
bind:value={password} 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" placeholder="Create a password"
/> />
</div> </div>
</div> </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="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <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"> <svg class="h-5 w-5 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
@@ -138,7 +138,7 @@
autocomplete="new-password" autocomplete="new-password"
required required
bind:value={confirmPassword} 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" placeholder="Confirm your password"
/> />
</div> </div>
@@ -165,7 +165,7 @@
</form> </form>
<div class="mt-6 text-center"> <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? 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> <a href="/login" class="font-medium text-green-600 hover:text-green-500 transition-colors duration-200">Sign in here</a>
</p> </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> </tr>
</thead> </thead>
<tbody> <tbody>
{#each listings as listing} {#each listings.filter(l => l.phone) as listing}
<tr> <tr>
<td>{listing.company_name}</td> <td>{listing.company_name}</td>
<td> <td>
@@ -427,7 +427,7 @@
<span class="badge badge-neutral">No</span> <span class="badge badge-neutral">No</span>
{/if} {/if}
</td> </td>
<td>{listing.phone || 'N/A'}</td> <td>{listing.phone || ''}</td>
<td> <td>
{#if listing.is_active} {#if listing.is_active}
<span class="badge badge-success">Active</span> <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: '', phone: '',
state: '', state: '',
countyId: 0, countyId: 0,
town: '' town: '',
url: ''
}; };
// Active status // Active status
@@ -143,7 +144,9 @@
phone: formData.phone, phone: formData.phone,
online_ordering: onlineOrdering, online_ordering: onlineOrdering,
county_id: formData.countyId, county_id: formData.countyId,
town: formData.town.trim() || null
town: formData.town.trim() || null,
url: formData.url.trim() || null
}); });
if (result.error) { if (result.error) {
@@ -239,6 +242,20 @@
{/if} {/if}
</div> </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 --> <!-- Pricing Section -->
<div class="bg-base-200 p-4 rounded-lg"> <div class="bg-base-200 p-4 rounded-lg">
<h3 class="text-lg font-semibold mb-3">Pricing Information</h3> <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: '', phone: '',
state: '', state: '',
countyId: 0, countyId: 0,
town: '' town: '',
url: ''
}; };
// Active status // Active status
@@ -78,6 +79,7 @@
onlineOrdering = listing.online_ordering; onlineOrdering = listing.online_ordering;
formData.countyId = listing.county_id; formData.countyId = listing.county_id;
formData.town = listing.town || ''; formData.town = listing.town || '';
formData.url = listing.url || '';
// Load the state for this county // Load the state for this county
await loadStateForCounty(listing.county_id); await loadStateForCounty(listing.county_id);
@@ -200,7 +202,8 @@
phone: formData.phone, phone: formData.phone,
online_ordering: onlineOrdering, online_ordering: onlineOrdering,
county_id: formData.countyId, county_id: formData.countyId,
town: formData.town.trim() || null town: formData.town.trim() || null,
url: formData.url.trim() || null
}); });
if (result.error) { if (result.error) {
@@ -305,6 +308,20 @@
{/if} {/if}
</div> </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 --> <!-- Pricing Section -->
<div class="bg-base-200 p-4 rounded-lg"> <div class="bg-base-200 p-4 rounded-lg">
<h3 class="text-lg font-semibold mb-3">Pricing Information</h3> <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

0
src/routes/+error.svelte Normal file → Executable file
View File

View File