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:
6
package-lock.json
generated
6
package-lock.json
generated
@@ -1255,9 +1255,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001664",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001664.tgz",
|
||||
"integrity": "sha512-AmE7k4dXiNKQipgn7a2xg558IRqPN3jMQY/rOsbxDhrd0tyChwbITBfiwtnqz8bi2M5mIWbxAYBvk7W7QBUS2g==",
|
||||
"version": "1.0.30001769",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz",
|
||||
"integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
||||
90
src/lib/api/client.ts
Normal file → Executable file
90
src/lib/api/client.ts
Normal file → Executable file
@@ -14,7 +14,10 @@ import type {
|
||||
UpdateListingRequest,
|
||||
OilPrice,
|
||||
County,
|
||||
ServiceCategory
|
||||
ServiceCategory,
|
||||
StatsPrice,
|
||||
UpdateUserRequest,
|
||||
UpdateOilPriceRequest
|
||||
} 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
|
||||
*/
|
||||
@@ -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
|
||||
*/
|
||||
@@ -319,5 +403,7 @@ export const api = {
|
||||
listings: listingsApi,
|
||||
oilPrices: oilPricesApi,
|
||||
state: stateApi,
|
||||
categories: categoriesApi
|
||||
categories: categoriesApi,
|
||||
stats: statsApi,
|
||||
admin: adminApi
|
||||
};
|
||||
|
||||
0
src/lib/api/index.ts
Normal file → Executable file
0
src/lib/api/index.ts
Normal file → Executable file
24
src/lib/api/types.ts
Normal file → Executable file
24
src/lib/api/types.ts
Normal file → Executable file
@@ -77,6 +77,7 @@ export interface Listing {
|
||||
online_ordering: string;
|
||||
county_id: number;
|
||||
town: string | null;
|
||||
url: string | null;
|
||||
user_id: number;
|
||||
created_at?: string;
|
||||
last_edited?: string;
|
||||
@@ -95,6 +96,7 @@ export interface CreateListingRequest {
|
||||
online_ordering?: string;
|
||||
county_id: number;
|
||||
town?: string | null;
|
||||
url?: string | null;
|
||||
}
|
||||
|
||||
export type UpdateListingRequest = Partial<CreateListingRequest>;
|
||||
@@ -109,6 +111,8 @@ export interface OilPrice {
|
||||
date: string | null;
|
||||
scrapetimestamp: string | null;
|
||||
county_id: number | null;
|
||||
phone: string | null;
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
// State/County Types
|
||||
@@ -126,3 +130,23 @@ export interface ServiceCategory {
|
||||
clicks_total: 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;
|
||||
|
||||
}
|
||||
|
||||
100
src/lib/components/AdminTable.svelte
Executable file
100
src/lib/components/AdminTable.svelte
Executable 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>
|
||||
@@ -1,11 +1,11 @@
|
||||
// src/lib/states.ts
|
||||
import { writable } from 'svelte/store';
|
||||
import { massachusettsCounties } from './states/massachusetts';
|
||||
import { maineCounties } from './states/maine';
|
||||
import { vermontCounties } from './states/vermont';
|
||||
import { newHampshireCounties } from './states/newhampshire';
|
||||
import { rhodeIslandCounties } from './states/rhodeisland';
|
||||
import { connecticutCounties } from './states/connecticut';
|
||||
import { massachusettsCounties, mapViewBox as maMapViewBox } from './states/massachusetts';
|
||||
import { maineCounties, mapViewBox as maineMapViewBox } from './states/maine';
|
||||
import { vermontCounties, mapViewBox as vermontMapViewBox } from './states/vermont';
|
||||
import { newHampshireCounties, mapViewBox as nhMapViewBox } from './states/newhampshire';
|
||||
import { rhodeIslandCounties, mapViewBox as riMapViewBox } from './states/rhodeisland';
|
||||
import { connecticutCounties, mapViewBox as ctMapViewBox } from './states/connecticut';
|
||||
|
||||
export interface NewEnglandState {
|
||||
id: string;
|
||||
@@ -96,6 +96,17 @@ export const newEnglandStates: NewEnglandState[] = [
|
||||
|
||||
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.
|
||||
* Format: "rotate(angle cx cy)"
|
||||
|
||||
@@ -15,7 +15,7 @@ export const connecticutCounties: NewEnglandState[] = [
|
||||
name: 'Fairfield County',
|
||||
slug: 'fairfield',
|
||||
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',
|
||||
hoverFill: 'fill-green-700'
|
||||
},
|
||||
@@ -78,13 +78,16 @@ export const connecticutCounties: NewEnglandState[] = [
|
||||
name: 'Windham County',
|
||||
slug: 'windham',
|
||||
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',
|
||||
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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
5
src/lib/states/newhampshire.ts
Normal file → Executable 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 -->
|
||||
<!-- 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"
|
||||
|
||||
184
src/routes/(app)/[stateSlug]/[countySlug]/+page.svelte
Normal file → Executable file
184
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">
|
||||
<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,7 +748,8 @@
|
||||
<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">
|
||||
<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} />
|
||||
@@ -680,6 +762,22 @@
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<!-- Desktop table -->
|
||||
<div class="hidden md:block">
|
||||
<div class="price-table-wrap">
|
||||
@@ -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">
|
||||
<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>
|
||||
</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
0
src/routes/+error.svelte
Normal file → Executable file
0
src/routes/+error.svelte
Normal file → Executable file
0
static/.well-known/appspecific/com.chrome.devtools.json
Normal file → Executable file
0
static/.well-known/appspecific/com.chrome.devtools.json
Normal file → Executable file
Reference in New Issue
Block a user