feat(api): implement centralized API client and refactor vendor pages

Introduced a new API client in src/lib/api/ to handle requests securely. Refactored vendor pages to use this client. Updated authentication logic in layout and login pages.
This commit is contained in:
2026-02-09 16:25:55 -05:00
parent bd602d58ab
commit a5df1bcacb
14 changed files with 722 additions and 722 deletions

309
src/lib/api/client.ts Normal file
View File

@@ -0,0 +1,309 @@
import { PUBLIC_API_URL } from '$env/static/public';
import { goto } from '$app/navigation';
import type {
ApiResponse,
LoginRequest,
LoginResponse,
RegisterRequest,
User,
Company,
CreateCompanyRequest,
UpdateCompanyRequest,
Listing,
CreateListingRequest,
UpdateListingRequest,
County,
ServiceCategory
} from './types';
/**
* Handle 401 unauthorized responses - clear user data and redirect to login
* Note: We only clear localStorage user data, the httpOnly cookie is cleared server-side
*/
function handleUnauthorized(): void {
if (typeof window === 'undefined') return;
localStorage.removeItem('user');
goto('/login');
}
/**
* Core fetch wrapper with auth and error handling
* Uses credentials: 'include' for cookie-based authentication
*/
async function request<T>(
endpoint: string,
options: RequestInit = {},
requiresAuth: boolean = false
): Promise<ApiResponse<T>> {
const headers: HeadersInit = {
...options.headers,
};
// Add Content-Type for JSON body
if (options.body && typeof options.body === 'string') {
(headers as Record<string, string>)['Content-Type'] = 'application/json';
}
// Check if we have a user in localStorage for auth-required requests
if (requiresAuth) {
const user = authApi.getStoredUser();
if (!user) {
handleUnauthorized();
return { data: null, error: 'Not authenticated' };
}
}
try {
const response = await fetch(`${PUBLIC_API_URL}${endpoint}`, {
...options,
headers,
// Include credentials (cookies) in all requests for httpOnly cookie auth
credentials: 'include',
});
// Handle 401 Unauthorized
if (response.status === 401) {
handleUnauthorized();
return { data: null, error: 'Session expired. Please log in again.' };
}
// Handle 404 Not Found - return null data without error for some cases
if (response.status === 404) {
return { data: null, error: 'Not found' };
}
// Handle other error responses
if (!response.ok) {
try {
const errorData = await response.json();
return { data: null, error: errorData.error || `Request failed with status ${response.status}` };
} catch {
return { data: null, error: `Request failed with status ${response.status}` };
}
}
// Handle empty responses (204 No Content, DELETE responses, etc.)
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
return { data: null as T, error: null };
}
const data = await response.json();
return { data, error: null };
} catch (err) {
const message = err instanceof Error ? err.message : 'Network error';
return { data: null, error: message };
}
}
/**
* Auth API methods
*/
export const authApi = {
/**
* Login with username and password
* Server sets JWT as httpOnly cookie, we store user info in localStorage
*/
async login(credentials: LoginRequest): Promise<ApiResponse<LoginResponse>> {
const result = await request<LoginResponse>('/auth/login', {
method: 'POST',
body: JSON.stringify(credentials),
});
// Store user on successful login (token is now in httpOnly cookie)
if (result.data) {
localStorage.setItem('user', JSON.stringify(result.data.user));
}
return result;
},
/**
* Register a new user
*/
async register(data: RegisterRequest): Promise<ApiResponse<{ message: string }>> {
return request('/auth/register', {
method: 'POST',
body: JSON.stringify(data),
});
},
/**
* Logout - calls server to clear httpOnly cookie and clears local storage
*/
async logout(): Promise<void> {
// Call server to clear the httpOnly cookie
await request('/auth/logout', {
method: 'POST',
});
// Clear local storage
localStorage.removeItem('user');
},
/**
* Get the currently logged-in user from localStorage
*/
getStoredUser(): User | null {
if (typeof window === 'undefined') return null;
const userStr = localStorage.getItem('user');
if (!userStr) return null;
try {
return JSON.parse(userStr);
} catch {
return null;
}
},
/**
* Check if user is authenticated
* Since httpOnly cookies can't be read via JS, we check localStorage user presence
* The actual auth is verified server-side when making requests
*/
isAuthenticated(): boolean {
return !!this.getStoredUser();
}
};
/**
* Company API methods
*/
export const companyApi = {
/**
* Get the current user's company
*/
async get(): Promise<ApiResponse<Company>> {
return request<Company>('/company', {}, true);
},
/**
* Create a new company
*/
async create(data: CreateCompanyRequest): Promise<ApiResponse<Company>> {
return request<Company>('/company', {
method: 'POST',
body: JSON.stringify(data),
}, true);
},
/**
* Update the current user's company
*/
async update(data: UpdateCompanyRequest): Promise<ApiResponse<Company>> {
return request<Company>('/company', {
method: 'PUT',
body: JSON.stringify(data),
}, true);
},
/**
* Delete (soft-delete) the current user's company
*/
async delete(): Promise<ApiResponse<null>> {
return request<null>('/company', {
method: 'DELETE',
}, true);
}
};
/**
* Listing API methods (for authenticated user's listings)
*/
export const listingApi = {
/**
* Get all listings for the current user
*/
async getAll(): Promise<ApiResponse<Listing[]>> {
return request<Listing[]>('/listing', {}, true);
},
/**
* Get a specific listing by ID
*/
async getById(id: number): Promise<ApiResponse<Listing>> {
return request<Listing>(`/listing/${id}`, {}, true);
},
/**
* Create a new listing
*/
async create(data: CreateListingRequest): Promise<ApiResponse<Listing>> {
return request<Listing>('/listing', {
method: 'POST',
body: JSON.stringify(data),
}, true);
},
/**
* Update a listing
*/
async update(id: number, data: UpdateListingRequest): Promise<ApiResponse<Listing>> {
return request<Listing>(`/listing/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}, true);
},
/**
* Delete a listing
*/
async delete(id: number): Promise<ApiResponse<null>> {
return request<null>(`/listing/${id}`, {
method: 'DELETE',
}, true);
}
};
/**
* Public listings API methods
*/
export const listingsApi = {
/**
* Get all listings for a county (public)
*/
async getByCounty(countyId: number): Promise<ApiResponse<Listing[]>> {
return request<Listing[]>(`/listings/county/${countyId}`);
}
};
/**
* State/County API methods
*/
export const stateApi = {
/**
* Get all counties for a state
*/
async getCounties(stateAbbr: string): Promise<ApiResponse<County[]>> {
return request<County[]>(`/state/${stateAbbr.toUpperCase()}`);
},
/**
* Get a specific county by ID within a state
*/
async getCounty(stateAbbr: string, countyId: string | number): Promise<ApiResponse<County>> {
return request<County>(`/state/${stateAbbr.toUpperCase()}/${countyId}`);
}
};
/**
* Categories API methods
*/
export const categoriesApi = {
/**
* Get all service categories
*/
async getAll(): Promise<ApiResponse<ServiceCategory[]>> {
return request<ServiceCategory[]>('/categories');
}
};
/**
* Unified API object for convenient imports
*/
export const api = {
auth: authApi,
company: companyApi,
listing: listingApi,
listings: listingsApi,
state: stateApi,
categories: categoriesApi
};

3
src/lib/api/index.ts Normal file
View File

@@ -0,0 +1,3 @@
// API Client exports
export { api, authApi, companyApi, listingApi, listingsApi, stateApi, categoriesApi } from './client';
export * from './types';

116
src/lib/api/types.ts Normal file
View File

@@ -0,0 +1,116 @@
// API Response Types
/**
* Generic API response wrapper
* Success: { data: T, error: null }
* Error: { data: null, error: string }
*/
export type ApiResponse<T> =
| { data: T; error: null }
| { data: null; error: string };
// Auth Types
export interface LoginRequest {
username: string;
password: string;
}
export interface RegisterRequest {
email: string;
username: string;
password: string;
}
export interface LoginResponse {
token: string;
user: User;
}
// User Types
export interface User {
id: number;
username: string;
email: string;
}
// Company Types
export interface Company {
id: number;
active: boolean;
created: string;
name: string;
address?: string | null;
town?: string | null;
state?: string | null;
phone?: string | null;
owner_name?: string | null;
owner_phone_number?: string | null;
email?: string | null;
user_id?: number | null;
}
export interface CreateCompanyRequest {
name: string;
address?: string | null;
town?: string | null;
state?: string | null;
phone?: string | null;
owner_name?: string | null;
owner_phone_number?: string | null;
email?: string | null;
}
export type UpdateCompanyRequest = Partial<CreateCompanyRequest>;
// Listing Types
export interface Listing {
id: number;
company_name: string;
is_active: boolean;
price_per_gallon: number;
price_per_gallon_cash: number | null;
note: string | null;
minimum_order: number | null;
service: boolean;
bio_percent: number;
phone: string | null;
online_ordering: string;
county_id: number;
town: string | null;
user_id: number;
created_at?: string;
last_edited?: string;
}
export interface CreateListingRequest {
company_name: string;
is_active?: boolean;
price_per_gallon: number;
price_per_gallon_cash?: number | null;
note?: string | null;
minimum_order?: number | null;
service?: boolean;
bio_percent: number;
phone?: string | null;
online_ordering?: string;
county_id: number;
town?: string | null;
}
export type UpdateListingRequest = Partial<CreateListingRequest>;
// State/County Types
export interface County {
id: number;
name: string;
state: string;
}
// Service Category Types
export interface ServiceCategory {
id: number;
name: string;
description: string;
clicks_total: number;
total_companies: number;
}

View File

@@ -4,6 +4,7 @@
import type { Writable } from 'svelte/store';
import '../../app.postcss'; // Import Tailwind CSS
import { user, darkMode, type User } from '$lib/states';
import { authApi } from '$lib/api';
// Initialize dark mode on mount to ensure data-theme is set
onMount(() => {
@@ -18,17 +19,15 @@
// Placeholder for user store - in a real app, this would be managed by an auth library or context
let storedUser: User | null = null;
// Check for user session on mount (this is a placeholder, actual implementation may vary)
// Check for user session on mount
onMount(() => {
const storedUserString = localStorage.getItem('user');
const token = localStorage.getItem('auth_token');
if (storedUserString && token) {
if (storedUserString) {
storedUser = JSON.parse(storedUserString);
user.set(storedUser);
} else {
// Clear if inconsistent
// No user stored
localStorage.removeItem('user');
localStorage.removeItem('auth_token');
user.set(null);
}
});
@@ -41,11 +40,10 @@
}
});
// Logout function
const logout = () => {
// Logout function - now async to call API to clear httpOnly cookie
const logout = async () => {
await authApi.logout();
user.set(null);
localStorage.removeItem('user');
localStorage.removeItem('auth_token');
window.location.href = '/';
};

View File

@@ -1,167 +0,0 @@
<!-- Catch-all route for state and county pages: /[stateSlug] and /[stateSlug]/[countyId] -->
<script lang="ts">
import { newEnglandStates, type NewEnglandState } from '$lib/states';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { browser } from '$app/environment';
import { onMount } from 'svelte';
interface County {
id: number;
name: string;
state: string;
}
const { slugs } = $page.params as { slugs: string };
let currentState: NewEnglandState | null = null;
let counties: County[] = [];
let currentCounty: County | null = null;
let loading = false;
let error: string | null = null;
let hoveredState: string | null = null;
// Parse the URL slugs: /stateSlug(/countyId)
$: {
if (slugs) {
const slugParts = slugs.split('/');
// First part is state slug: /MA(/123)
if (slugParts.length >= 1) {
const stateSlug = slugParts[0];
currentState = newEnglandStates.find(s => s.id === stateSlug) || null;
// Second part is county ID: /MA/123
if (slugParts.length >= 2 && !isNaN(Number(slugParts[1]))) {
const countyId = Number(slugParts[1]);
currentCounty = counties.find(c => c.id === countyId) || null;
}
}
}
}
onMount(async () => {
loading = true;
try {
// Trigger reactive parsing
if (slugs) {
const slugParts = slugs.split('/');
// If we have state data, fetch counties
if (slugParts.length >= 1) {
const stateSlug = slugParts[0];
currentState = newEnglandStates.find(s => s.id === stateSlug) || null;
if (currentState) {
const countyResponse = await fetch(`http://localhost:9552/state/${stateSlug.toUpperCase()}`);
if (countyResponse.ok) {
counties = await countyResponse.json();
}
// Handle county data
if (slugParts.length >= 2 && !isNaN(Number(slugParts[1]))) {
const countyId = Number(slugParts[1]);
const countyResponse = await fetch(`http://localhost:9552/state/${stateSlug.toUpperCase()}/${countyId}`);
if (countyResponse.ok) {
currentCounty = await countyResponse.json();
}
}
}
}
}
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load data';
console.error('Error:', err);
} finally {
loading = false;
}
});
function handleStateClick(stateId: string) {
goto(`/${stateId}`);
}
function handleCountyClick(countyId: number) {
if (currentState) {
goto(`/${currentState.id}/${countyId}`);
}
}
function handleMouseEnter(stateId: string) {
if (browser) {
hoveredState = stateId;
}
}
function handleMouseLeave() {
if (browser) {
hoveredState = null;
}
}
</script>
{#if loading}
<div class="text-center py-8">
<p>Loading...</p>
</div>
{:else if error}
<div class="text-center py-8">
<p class="text-error">Error: {error}</p>
</div>
{:else if currentCounty && currentState}
<!-- County Details Page -->
<div class="card lg:card-side bg-base-100 shadow-xl">
<div class="card-body">
<h1 class="card-title text-3xl">{currentCounty.name}, {currentState.name}</h1>
<p>This is the page for {currentCounty.name} county in {currentState.name}.</p>
<p>The URL for this page is <code class="bg-base-300 p-1 rounded">/{currentState.id}/{currentCounty.id}</code></p>
<div class="card-actions justify-end mt-4">
<a href="/{currentState.id}" class="btn btn-primary">Back to State</a>
<a href="/" class="btn btn-secondary">Back to Map</a>
</div>
</div>
</div>
{:else if currentState}
<!-- State Page with Counties -->
<div class="card lg:card-side bg-base-100 shadow-xl">
<figure class="flex-shrink-0">
<img
src={currentState.image}
alt="Map or notable feature of {currentState.id}"
class="object-cover w-full h-64 lg:h-auto lg:w-64"
/>
</figure>
<div class="card-body">
<h1 class="card-title text-3xl">{currentState.name}</h1>
<p>The URL for this page is <code class="bg-base-300 p-1 rounded">/{currentState.id}</code></p>
{#if counties.length > 0}
<div class="mt-4">
<h2 class="text-xl font-semibold mb-2">Counties:</h2>
<ul class="list-disc pl-5 max-h-48 overflow-y-auto">
{#each counties as county}
<li>
<a
href="/{currentState.id}/{county.id}"
class="text-blue-600 hover:underline"
>
{county.name}
</a>
</li>
{/each}
</ul>
</div>
{/if}
<div class="card-actions justify-end mt-4">
<a href="/" class="btn btn-primary">Back to Map</a>
</div>
</div>
</div>
{:else}
<div class="text-center py-10">
<h1 class="text-3xl font-bold text-error">Invalid URL</h1>
<p class="mt-4">The requested page could not be found.</p>
<a href="/" class="btn btn-primary mt-6">Go Back to Map</a>
</div>
{/if}

View File

@@ -14,12 +14,14 @@
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { api } from '$lib/api';
import type { County } from '$lib/api';
const { stateSlug } = $page.params as { stateSlug: string };
let stateData: NewEnglandState | undefined;
let stateCounties: NewEnglandState[] = [];
let filteredCounties: any[] = [];
let filteredCounties: County[] = [];
let loading = false;
let error: string | null = null;
let hoveredCounty: string | null = null;
@@ -41,7 +43,7 @@
function handleCountyClick(county: NewEnglandState) {
// Match county names between map data and API data
const cleanMapName = county.name.toLowerCase().replace(/\s+county$/, '').replace(/[^a-z]/g, '');
const apiCounty = filteredCounties.find((c: any) =>
const apiCounty = filteredCounties.find((c: County) =>
cleanMapName === c.name.toLowerCase().replace(/[^a-z]/g, '')
);
@@ -65,16 +67,16 @@
if (stateData) {
// Load API county data
loading = true;
try {
const token = localStorage.getItem('auth_token');
const response = await fetch(`http://localhost:9552/state/${stateSlug.toUpperCase()}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
filteredCounties = response.ok ? await response.json() : [];
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to fetch counties';
const result = await api.state.getCounties(stateSlug);
if (result.error) {
error = result.error;
filteredCounties = [];
} else {
filteredCounties = result.data || [];
}
loading = false;
// Load map county data

View File

@@ -3,27 +3,11 @@
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { newEnglandStates } from '../../../../lib/states';
interface Listing {
id: number;
company_name: string;
is_active: boolean;
price_per_gallon: number;
price_per_gallon_cash: number | null;
note: string | null;
minimum_order: number | null;
service: boolean;
bio_percent: number;
phone: string | null;
online_ordering: string;
county_id: number;
town: string | null;
user_id: number;
last_edited: string;
}
import { api } from '$lib/api';
import type { Listing, County } from '$lib/api';
const { stateSlug, countySlug } = $page.params as { stateSlug: string; countySlug: string };
let countyData: { id: number; name: string; state: string } | null = null;
let countyData: County | null = null;
let listings: Listing[] = [];
let loading = true;
let listingsLoading = false;
@@ -33,29 +17,18 @@
let sortDirection = 'asc'; // 'asc' or 'desc' - lowest price first
onMount(async () => {
try {
// Ensure API URL matches the Docker port forwarding for API (9552)
const token = localStorage.getItem('auth_token');
const headers: Record<string, string> = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`http://localhost:9552/state/${stateSlug.toUpperCase()}/${countySlug}`, {
headers
});
if (!response.ok) {
throw new Error(`Failed to fetch county data: ${response.statusText}`);
}
countyData = await response.json();
const result = await api.state.getCounty(stateSlug, countySlug);
if (result.error) {
error = result.error;
countyData = null;
} else {
countyData = result.data;
// Fetch listings for this county
await fetchListings();
} catch (err) {
error = err instanceof Error ? err.message : 'An error occurred while fetching county data.';
countyData = null;
} finally {
loading = false;
}
loading = false;
});
async function fetchListings() {
@@ -63,20 +36,18 @@
listingsLoading = true;
listingsError = null;
try {
const response = await fetch(`http://localhost:9552/listings/county/${countyData.id}`);
if (response.ok) {
listings = await response.json();
sortListings();
} else {
const result = await api.listings.getByCounty(countyData.id);
if (result.error) {
listingsError = 'Failed to load listings';
} else {
listings = result.data || [];
sortListings();
}
} catch (err) {
listingsError = 'Network error loading listings';
} finally {
listingsLoading = false;
}
}
function sortListings() {
listings = [...listings].sort((a, b) => {
@@ -260,7 +231,7 @@
</div>
</div>
</td>
<td>{new Date(listing.last_edited).toLocaleDateString()}</td>
<td>{listing.last_edited ? new Date(listing.last_edited).toLocaleDateString() : 'N/A'}</td>
</tr>
{/each}
</tbody>
@@ -319,7 +290,7 @@
</div>
<div>
<span class="font-semibold">Last Updated:</span><br>
<small>{new Date(listing.last_edited).toLocaleDateString()}</small>
<small>{listing.last_edited ? new Date(listing.last_edited).toLocaleDateString() : 'N/A'}</small>
</div>
</div>
</div>

View File

@@ -1,7 +1,8 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { user } from '$lib/states';
import type { LoginRequest } from '$lib/types/types';
import { api } from '$lib/api';
import type { LoginRequest } from '$lib/api';
let username = '';
let password = '';
@@ -17,40 +18,19 @@
isLoading = true;
errorMessage = '';
try {
const response = await fetch('http://localhost:9552/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(loginData)
});
if (!response.ok) {
const errorData = await response.json();
errorMessage = errorData.error || 'Login failed';
} else {
const data = await response.json();
// Assuming the backend returns a token or some success indicator
// Store the token in localStorage for authentication checks
if (data.token) {
localStorage.setItem('auth_token', data.token);
}
// Store the user object in localStorage
localStorage.setItem('user', JSON.stringify(data.user));
const result = await api.auth.login(loginData);
if (result.error) {
errorMessage = result.error;
} else if (result.data) {
// Update the user store
user.set(data.user);
user.set(result.data.user);
// Redirect to vendor page after successful login
goto('/vendor');
}
} catch (err) {
errorMessage = 'An error occurred. Please try again.';
} finally {
isLoading = false;
}
}
</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">

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import type { RegisterRequest } from '$lib/types/types';
import { api } from '$lib/api';
import type { RegisterRequest } from '$lib/api';
let email = '';
let username = '';
@@ -25,34 +25,17 @@
isLoading = true;
errorMessage = '';
try {
const response = await fetch('http://localhost:9552/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(registerData)
});
const result = await api.auth.register(registerData);
if (!response.ok) {
const errorData = await response.json();
errorMessage = errorData.error || 'Registration failed';
if (result.error) {
errorMessage = result.error;
} else {
const data = await response.json();
// Store the token in localStorage for authentication checks
if (data.token) {
localStorage.setItem('auth_token', data.token);
// Redirect to login page after successful registration
goto('/login');
}
// Redirect to vendor page after successful registration
goto('/vendor');
}
} catch (err) {
console.error('Registration error:', err);
errorMessage = 'An error occurred. Please try again. Check the console for details.';
} finally {
isLoading = false;
}
}
</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">

View File

@@ -1,15 +1,21 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { authApi } from '$lib/api';
onMount(() => {
// Check if the user is logged in by looking for a token in localStorage or a cookie
// This assumes a token is stored upon successful login
const token = localStorage.getItem('auth_token');
if (!token) {
// Redirect to home page if not logged in
onMount(async () => {
// Check if the user is logged in by checking localStorage user
// and verifying with the API (which checks the httpOnly cookie)
const storedUser = authApi.getStoredUser();
if (!storedUser) {
// No user stored, redirect to home
goto('/');
return;
}
// Optional: Verify auth by making a request to /user endpoint
// This ensures the httpOnly cookie is still valid
// Note: We could skip this for faster loading, as protected API calls will handle 401
});
</script>

View File

@@ -1,38 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
interface Listing {
id: number;
company_name: string;
is_active: boolean;
price_per_gallon: number;
price_per_gallon_cash: number | null;
note: string | null;
minimum_order: number | null;
service: boolean;
bio_percent: number;
phone: string | null;
online_ordering: string;
county_id: number;
town: string | null;
user_id: number;
last_edited: string;
}
interface Company {
id: number;
active: boolean;
created: string;
name: string;
address?: string;
town?: string;
state?: string;
phone?: string;
owner_name?: string;
owner_phone_number?: string;
email?: string;
user_id?: number;
}
import { api } from '$lib/api';
import type { Listing, Company } from '$lib/api';
let listings: Listing[] = [];
let company: Company | null = null;
@@ -48,96 +17,44 @@
let editingValue: string = '';
async function fetchCompany() {
try {
const token = localStorage.getItem('auth_token');
const result = await api.company.get();
if (!token) {
// Redirect to login if no token
window.location.href = '/login';
return;
}
const response = await fetch('http://localhost:9552/company', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
company = await response.json();
} else if (response.status === 401) {
// Token expired or invalid, redirect to login
localStorage.removeItem('auth_token');
localStorage.removeItem('user');
window.location.href = '/login';
return;
} else if (response.status === 404) {
if (result.error) {
if (result.error === 'Not found') {
// Company not found - that's okay, user can create one
company = null;
} else {
} else if (result.error !== 'Session expired. Please log in again.') {
companyError = 'Failed to load company information';
}
} catch (err) {
companyError = 'Network error loading company information';
} finally {
companyLoading = false;
} else {
company = result.data;
}
companyLoading = false;
}
async function fetchListings() {
try {
const token = localStorage.getItem('auth_token');
const result = await api.listing.getAll();
if (!token) {
// Redirect to login if no token
window.location.href = '/login';
return;
}
const response = await fetch('http://localhost:9552/listing', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
listings = await response.json();
} else if (response.status === 401) {
// Token expired or invalid, redirect to login
localStorage.removeItem('auth_token');
localStorage.removeItem('user');
window.location.href = '/login';
return;
} else {
if (result.error) {
if (result.error !== 'Session expired. Please log in again.') {
error = 'Failed to load listings';
}
} catch (err) {
error = 'Network error loading listings';
} finally {
isLoading = false;
} else {
listings = result.data || [];
}
isLoading = false;
}
async function deleteListing(id: number) {
if (!confirm('Are you sure you want to delete this listing?')) return;
try {
const token = localStorage.getItem('auth_token');
const response = await fetch(`http://localhost:9552/listing/${id}`, {
method: 'DELETE',
headers: {
'Authorization': token ? `Bearer ${token}` : ''
}
});
const result = await api.listing.delete(id);
if (response.ok) {
if (result.error) {
alert('Failed to delete listing');
} else {
// Remove from local state
listings = listings.filter(listing => listing.id !== id);
} else {
alert('Failed to delete listing');
}
} catch (err) {
alert('Network error deleting listing');
}
}
@@ -171,44 +88,37 @@
async function saveEditing(listing: Listing) {
if (!editingField) return;
let updateData: any = {};
let newValue: any;
let updateData: Record<string, unknown> = {};
let newValue: unknown;
try {
if (editingField === 'price_per_gallon' || editingField === 'price_per_gallon_cash') {
newValue = editingValue ? parseFloat(editingValue) : null;
if (editingField === 'price_per_gallon' && (!newValue || newValue <= 0)) {
if (editingField === 'price_per_gallon' && (!newValue || (newValue as number) <= 0)) {
alert('Please enter a valid price greater than 0');
return;
}
updateData[editingField] = newValue;
} else if (editingField === 'minimum_order') {
newValue = editingValue ? parseInt(editingValue) : null;
if (newValue && (newValue < 1 || newValue > 200)) {
if (newValue && ((newValue as number) < 1 || (newValue as number) > 200)) {
alert('Minimum order must be between 1 and 200');
return;
}
updateData[editingField] = newValue;
} else if (editingField === 'note') {
newValue = editingValue.trim() || null;
if (newValue && newValue.length > 250) {
if (newValue && (newValue as string).length > 250) {
alert('Note cannot exceed 250 characters');
return;
}
updateData[editingField] = newValue;
}
const token = localStorage.getItem('auth_token');
const response = await fetch(`http://localhost:9552/listing/${listing.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : ''
},
body: JSON.stringify(updateData)
});
const result = await api.listing.update(listing.id, updateData);
if (response.ok) {
if (result.error) {
alert(`Failed to update ${editingField}`);
} else {
// Update local state
listings = listings.map(l =>
l.id === listing.id
@@ -222,11 +132,6 @@
setTimeout(() => {
successMessage = '';
}, 3000);
} else {
alert(`Failed to update ${editingField}`);
}
} catch (err) {
alert('Network error updating field');
}
}
@@ -248,6 +153,10 @@
}
onMount(() => {
if (!api.auth.isAuthenticated()) {
window.location.href = '/login';
return;
}
fetchCompany();
fetchListings();
});

View File

@@ -1,13 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { newEnglandStates } from '$lib/states';
interface County {
id: number;
name: string;
state: string;
}
import { api } from '$lib/api';
import type { County } from '$lib/api';
// Form data
let formData = {
@@ -137,15 +132,7 @@
isSubmitting = true;
submitMessage = '';
try {
const token = localStorage.getItem('token');
const response = await fetch('http://localhost:9552/listing', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : ''
},
body: JSON.stringify({
const result = await api.listing.create({
company_name: formData.companyName,
is_active: isActive,
price_per_gallon: formData.pricePerGallon,
@@ -157,25 +144,18 @@
online_ordering: onlineOrdering,
county_id: formData.countyId,
town: formData.town.trim() || null
})
});
if (response.ok) {
if (result.error) {
submitMessage = result.error === 'Session expired. Please log in again.'
? result.error
: 'Failed to create listing. Please try again.';
} else {
// Redirect to vendor dashboard on success
goto('/vendor');
} else {
submitMessage = 'Failed to create listing. Please try again.';
}
} catch (error) {
submitMessage = 'Network error. Please check your connection and try again.';
} finally {
isSubmitting = false;
}
}
// Format price for display
function formatPrice(value: number): string {
return value.toFixed(2);
isSubmitting = false;
}
// Handle state change
@@ -187,24 +167,21 @@
if (formData.state) {
isLoadingCounties = true;
try {
const response = await fetch(`http://localhost:9552/state/${formData.state}`);
if (response.ok) {
counties = await response.json();
const result = await api.state.getCounties(formData.state);
if (result.error) {
errors.countyId = result.error;
} else {
counties = result.data || [];
if (counties.length === 0) {
errors.countyId = 'No counties found for selected state';
}
} else {
const errorData = await response.json();
errors.countyId = errorData.error || 'Failed to load counties';
}
} catch (error) {
errors.countyId = 'Network error loading counties';
} finally {
isLoadingCounties = false;
}
}
}
</script>
<!-- Breadcrumbs (full width, left aligned) -->

View File

@@ -3,30 +3,8 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { newEnglandStates } from '$lib/states';
interface County {
id: number;
name: string;
state: string;
}
interface Listing {
id: number;
company_name: string;
is_active: boolean;
price_per_gallon: number;
price_per_gallon_cash: number | null;
note: string | null;
minimum_order: number | null;
service: boolean;
bio_percent: number;
phone: string | null;
online_ordering: string;
county_id: number;
town: string | null;
user_id: number;
last_edited: string;
}
import { api } from '$lib/api';
import type { County, Listing } from '$lib/api';
// Get listing ID from URL params
let listingId: string;
@@ -79,16 +57,14 @@
// Load existing listing data
async function loadListing() {
try {
const token = localStorage.getItem('auth_token');
const response = await fetch(`http://localhost:9552/listing/${listingId}`, {
headers: {
'Authorization': token ? `Bearer ${token}` : ''
}
});
const result = await api.listing.getById(parseInt(listingId));
if (response.ok) {
const listing: Listing = await response.json();
if (result.error) {
submitMessage = result.error === 'Session expired. Please log in again.'
? result.error
: 'Failed to load listing data';
} else if (result.data) {
const listing = result.data;
// Pre-populate form with existing data
formData.companyName = listing.company_name;
@@ -105,25 +81,20 @@
// Load the state for this county
await loadStateForCounty(listing.county_id);
} else {
submitMessage = 'Failed to load listing data';
}
} catch (error) {
submitMessage = 'Network error loading listing data';
} finally {
isLoading = false;
}
}
// Load state information for a given county
async function loadStateForCounty(countyId: number) {
// We need to find which state this county belongs to
// Since we don't have a reverse lookup, we'll load counties for each state until we find it
for (const state of newEnglandStates) {
try {
const response = await fetch(`http://localhost:9552/state/${state.id}`);
if (response.ok) {
const stateCounties: County[] = await response.json();
const result = await api.state.getCounties(state.id);
if (!result.error && result.data) {
const stateCounties = result.data;
const county = stateCounties.find(c => c.id === countyId);
if (county) {
formData.state = state.id;
@@ -131,9 +102,6 @@
break;
}
}
} catch (error) {
// Continue to next state
}
}
}
@@ -221,15 +189,7 @@
isSubmitting = true;
submitMessage = '';
try {
const token = localStorage.getItem('auth_token');
const response = await fetch(`http://localhost:9552/listing/${listingId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : ''
},
body: JSON.stringify({
const result = await api.listing.update(parseInt(listingId), {
company_name: formData.companyName,
is_active: isActive,
price_per_gallon: formData.pricePerGallon,
@@ -241,25 +201,18 @@
online_ordering: onlineOrdering,
county_id: formData.countyId,
town: formData.town.trim() || null
})
});
if (response.ok) {
if (result.error) {
submitMessage = result.error === 'Session expired. Please log in again.'
? result.error
: 'Failed to update listing. Please try again.';
} else {
// Redirect to vendor dashboard on success
goto('/vendor');
} else {
submitMessage = 'Failed to update listing. Please try again.';
}
} catch (error) {
submitMessage = 'Network error. Please check your connection and try again.';
} finally {
isSubmitting = false;
}
}
// Format price for display
function formatPrice(value: number): string {
return value.toFixed(2);
isSubmitting = false;
}
// Handle state change
@@ -271,24 +224,21 @@
if (formData.state) {
isLoadingCounties = true;
try {
const response = await fetch(`http://localhost:9552/state/${formData.state}`);
if (response.ok) {
counties = await response.json();
const result = await api.state.getCounties(formData.state);
if (result.error) {
errors.countyId = result.error;
} else {
counties = result.data || [];
if (counties.length === 0) {
errors.countyId = 'No counties found for selected state';
}
} else {
const errorData = await response.json();
errors.countyId = errorData.error || 'Failed to load counties';
}
} catch (error) {
errors.countyId = 'Network error loading counties';
} finally {
isLoadingCounties = false;
}
}
}
onMount(() => {
loadListing();

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
const states = ['ME', 'NH', 'VT', 'MA', 'RI', 'CT'];
@@ -20,36 +21,28 @@
let deleting = false;
async function fetchCompany() {
try {
const response = await fetch('http://localhost:9552/company', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
}
});
const result = await api.company.get();
if (response.ok) {
const data = await response.json();
if (result.data) {
company = {
name: data.name || '',
address: data.address || '',
town: data.town || '',
state: data.state || '',
phone: data.phone ? data.phone.toString() : '',
owner_name: data.owner_name || '',
owner_phone_number: data.owner_phone_number ? data.owner_phone_number.toString() : '',
email: data.email || ''
name: result.data.name || '',
address: result.data.address || '',
town: result.data.town || '',
state: result.data.state || '',
phone: result.data.phone ? result.data.phone.toString() : '',
owner_name: result.data.owner_name || '',
owner_phone_number: result.data.owner_phone_number ? result.data.owner_phone_number.toString() : '',
email: result.data.email || ''
};
} else if (result.error && result.error !== 'Not found') {
console.error('Error fetching company:', result.error);
}
} catch (error) {
console.error('Error fetching company:', error);
} finally {
loading = false;
}
}
async function saveCompany() {
saving = true;
try {
const payload = {
name: company.name,
address: company.address || null,
@@ -63,37 +56,20 @@
console.log('Sending payload:', payload);
const response = await fetch('http://localhost:9552/company', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
},
body: JSON.stringify(payload)
});
const result = await api.company.update(payload);
if (response.ok) {
alert('Company profile saved successfully!');
} else if (response.status === 401) {
if (result.error) {
if (result.error === 'Session expired. Please log in again.') {
alert('Your session has expired. Please log in again.');
// Could redirect to login: goto('/login');
} else {
const errorText = await response.text();
console.log('Error response:', errorText);
try {
const error = JSON.parse(errorText);
alert('Error saving company: ' + error.error);
} catch (e) {
alert('Error saving company: ' + errorText);
alert('Error saving company: ' + result.error);
}
} else {
alert('Company profile saved successfully!');
}
} catch (error) {
console.error('Error saving company:', error);
alert('Error saving company');
} finally {
saving = false;
}
}
async function deleteCompany() {
if (!confirm('Are you sure you want to delete your company profile? This action cannot be undone.')) {
@@ -101,41 +77,28 @@
}
deleting = true;
try {
const response = await fetch('http://localhost:9552/company', {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
}
});
if (response.ok) {
const result = await api.company.delete();
if (result.error) {
if (result.error === 'Session expired. Please log in again.') {
alert('Your session has expired. Please log in again.');
} else {
alert('Error deleting company: ' + result.error);
}
} else {
alert('Company profile deleted successfully!');
goto('/vendor');
} else if (response.status === 401) {
alert('Your session has expired. Please log in again.');
// Could redirect to login: goto('/login');
} else {
const error = await response.json();
alert('Error deleting company: ' + error.error);
}
} catch (error) {
console.error('Error deleting company:', error);
alert('Error deleting company');
} finally {
deleting = false;
}
}
onMount(() => {
const token = localStorage.getItem('auth_token');
if (token) {
if (api.auth.isAuthenticated()) {
fetchCompany();
} else {
loading = false;
// Could optionally show a login prompt or redirect
// alert('Please log in to access your company profile.');
// goto('/login');
}
});
</script>