diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts new file mode 100644 index 0000000..7cdef85 --- /dev/null +++ b/src/lib/api/client.ts @@ -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( + endpoint: string, + options: RequestInit = {}, + requiresAuth: boolean = false +): Promise> { + const headers: HeadersInit = { + ...options.headers, + }; + + // Add Content-Type for JSON body + if (options.body && typeof options.body === 'string') { + (headers as Record)['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> { + const result = await request('/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> { + return request('/auth/register', { + method: 'POST', + body: JSON.stringify(data), + }); + }, + + /** + * Logout - calls server to clear httpOnly cookie and clears local storage + */ + async logout(): Promise { + // 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> { + return request('/company', {}, true); + }, + + /** + * Create a new company + */ + async create(data: CreateCompanyRequest): Promise> { + return request('/company', { + method: 'POST', + body: JSON.stringify(data), + }, true); + }, + + /** + * Update the current user's company + */ + async update(data: UpdateCompanyRequest): Promise> { + return request('/company', { + method: 'PUT', + body: JSON.stringify(data), + }, true); + }, + + /** + * Delete (soft-delete) the current user's company + */ + async delete(): Promise> { + return request('/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> { + return request('/listing', {}, true); + }, + + /** + * Get a specific listing by ID + */ + async getById(id: number): Promise> { + return request(`/listing/${id}`, {}, true); + }, + + /** + * Create a new listing + */ + async create(data: CreateListingRequest): Promise> { + return request('/listing', { + method: 'POST', + body: JSON.stringify(data), + }, true); + }, + + /** + * Update a listing + */ + async update(id: number, data: UpdateListingRequest): Promise> { + return request(`/listing/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }, true); + }, + + /** + * Delete a listing + */ + async delete(id: number): Promise> { + return request(`/listing/${id}`, { + method: 'DELETE', + }, true); + } +}; + +/** + * Public listings API methods + */ +export const listingsApi = { + /** + * Get all listings for a county (public) + */ + async getByCounty(countyId: number): Promise> { + return request(`/listings/county/${countyId}`); + } +}; + +/** + * State/County API methods + */ +export const stateApi = { + /** + * Get all counties for a state + */ + async getCounties(stateAbbr: string): Promise> { + return request(`/state/${stateAbbr.toUpperCase()}`); + }, + + /** + * Get a specific county by ID within a state + */ + async getCounty(stateAbbr: string, countyId: string | number): Promise> { + return request(`/state/${stateAbbr.toUpperCase()}/${countyId}`); + } +}; + +/** + * Categories API methods + */ +export const categoriesApi = { + /** + * Get all service categories + */ + async getAll(): Promise> { + return request('/categories'); + } +}; + +/** + * Unified API object for convenient imports + */ +export const api = { + auth: authApi, + company: companyApi, + listing: listingApi, + listings: listingsApi, + state: stateApi, + categories: categoriesApi +}; diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts new file mode 100644 index 0000000..2e2c872 --- /dev/null +++ b/src/lib/api/index.ts @@ -0,0 +1,3 @@ +// API Client exports +export { api, authApi, companyApi, listingApi, listingsApi, stateApi, categoriesApi } from './client'; +export * from './types'; diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts new file mode 100644 index 0000000..57a9265 --- /dev/null +++ b/src/lib/api/types.ts @@ -0,0 +1,116 @@ +// API Response Types + +/** + * Generic API response wrapper + * Success: { data: T, error: null } + * Error: { data: null, error: string } + */ +export type ApiResponse = + | { 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; + +// 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; + +// 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; +} diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index 491d096..2f3ab1b 100755 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -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 = '/'; }; diff --git a/src/routes/(app)/[...slugs]/+page.svelte b/src/routes/(app)/[...slugs]/+page.svelte deleted file mode 100644 index 291ac1a..0000000 --- a/src/routes/(app)/[...slugs]/+page.svelte +++ /dev/null @@ -1,167 +0,0 @@ - - - -{#if loading} -
-

Loading...

-
-{:else if error} -
-

Error: {error}

-
-{:else if currentCounty && currentState} - -
-
-

{currentCounty.name}, {currentState.name}

-

This is the page for {currentCounty.name} county in {currentState.name}.

-

The URL for this page is /{currentState.id}/{currentCounty.id}

- -
-
-{:else if currentState} - -
-
- Map or notable feature of {currentState.id} -
-
-

{currentState.name}

-

The URL for this page is /{currentState.id}

- - {#if counties.length > 0} -
-

Counties:

- -
- {/if} - - -
-
-{:else} -
-

Invalid URL

-

The requested page could not be found.

- Go Back to Map -
-{/if} diff --git a/src/routes/(app)/[stateSlug]/+page.svelte b/src/routes/(app)/[stateSlug]/+page.svelte index 35ae47b..79c2688 100755 --- a/src/routes/(app)/[stateSlug]/+page.svelte +++ b/src/routes/(app)/[stateSlug]/+page.svelte @@ -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 diff --git a/src/routes/(app)/[stateSlug]/[countySlug]/+page.svelte b/src/routes/(app)/[stateSlug]/[countySlug]/+page.svelte index 946e469..a5e6df7 100644 --- a/src/routes/(app)/[stateSlug]/[countySlug]/+page.svelte +++ b/src/routes/(app)/[stateSlug]/[countySlug]/+page.svelte @@ -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 = {}; - 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,19 +36,17 @@ 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 { - listingsError = 'Failed to load listings'; - } - } catch (err) { - listingsError = 'Network error loading listings'; - } finally { - listingsLoading = false; + + const result = await api.listings.getByCounty(countyData.id); + + if (result.error) { + listingsError = 'Failed to load listings'; + } else { + listings = result.data || []; + sortListings(); } + + listingsLoading = false; } function sortListings() { @@ -260,7 +231,7 @@ - {new Date(listing.last_edited).toLocaleDateString()} + {listing.last_edited ? new Date(listing.last_edited).toLocaleDateString() : 'N/A'} {/each} @@ -319,7 +290,7 @@
Last Updated:
- {new Date(listing.last_edited).toLocaleDateString()} + {listing.last_edited ? new Date(listing.last_edited).toLocaleDateString() : 'N/A'}
diff --git a/src/routes/(app)/login/+page.svelte b/src/routes/(app)/login/+page.svelte index b0603c8..4b0356c 100755 --- a/src/routes/(app)/login/+page.svelte +++ b/src/routes/(app)/login/+page.svelte @@ -1,7 +1,8 @@ diff --git a/src/routes/(app)/register/+page.svelte b/src/routes/(app)/register/+page.svelte index 679ae12..441d515 100755 --- a/src/routes/(app)/register/+page.svelte +++ b/src/routes/(app)/register/+page.svelte @@ -1,7 +1,7 @@ diff --git a/src/routes/(app)/vendor/+layout.svelte b/src/routes/(app)/vendor/+layout.svelte index a7bafa1..e0d9bdc 100644 --- a/src/routes/(app)/vendor/+layout.svelte +++ b/src/routes/(app)/vendor/+layout.svelte @@ -1,15 +1,21 @@ diff --git a/src/routes/(app)/vendor/+page.svelte b/src/routes/(app)/vendor/+page.svelte index 115392d..96398af 100644 --- a/src/routes/(app)/vendor/+page.svelte +++ b/src/routes/(app)/vendor/+page.svelte @@ -1,38 +1,7 @@ diff --git a/src/routes/(app)/vendor/listing/[id]/+page.svelte b/src/routes/(app)/vendor/listing/[id]/+page.svelte index 6fd7183..5374b59 100644 --- a/src/routes/(app)/vendor/listing/[id]/+page.svelte +++ b/src/routes/(app)/vendor/listing/[id]/+page.svelte @@ -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,40 +57,33 @@ // 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; - isActive = listing.is_active; - formData.pricePerGallon = listing.price_per_gallon; - formData.pricePerGallonCash = listing.price_per_gallon_cash; - formData.minimumOrder = listing.minimum_order; - formData.labelService = listing.service; - formData.bioPercent = listing.bio_percent; - formData.phone = listing.phone || ''; - onlineOrdering = listing.online_ordering; - formData.countyId = listing.county_id; - formData.town = listing.town || ''; + // Pre-populate form with existing data + formData.companyName = listing.company_name; + isActive = listing.is_active; + formData.pricePerGallon = listing.price_per_gallon; + formData.pricePerGallonCash = listing.price_per_gallon_cash; + formData.minimumOrder = listing.minimum_order; + formData.labelService = listing.service; + formData.bioPercent = listing.bio_percent; + formData.phone = listing.phone || ''; + onlineOrdering = listing.online_ordering; + formData.countyId = listing.county_id; + formData.town = listing.town || ''; - // 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 the state for this county + await loadStateForCounty(listing.county_id); } + + isLoading = false; } // Load state information for a given county @@ -120,19 +91,16 @@ // 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 county = stateCounties.find(c => c.id === countyId); - if (county) { - formData.state = state.id; - counties = stateCounties; - break; - } + 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; + counties = stateCounties; + break; } - } catch (error) { - // Continue to next state } } } @@ -221,45 +189,30 @@ 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({ - company_name: formData.companyName, - is_active: isActive, - price_per_gallon: formData.pricePerGallon, - price_per_gallon_cash: formData.pricePerGallonCash, - minimum_order: formData.minimumOrder, - service: formData.labelService, - bio_percent: formData.bioPercent, - phone: formData.phone, - online_ordering: onlineOrdering, - county_id: formData.countyId, - town: formData.town.trim() || null - }) - }); + const result = await api.listing.update(parseInt(listingId), { + company_name: formData.companyName, + is_active: isActive, + price_per_gallon: formData.pricePerGallon, + price_per_gallon_cash: formData.pricePerGallonCash, + minimum_order: formData.minimumOrder, + service: formData.labelService, + bio_percent: formData.bioPercent, + phone: formData.phone, + online_ordering: onlineOrdering, + county_id: formData.countyId, + town: formData.town.trim() || null + }); - if (response.ok) { - // 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; + 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'); } - } - // Format price for display - function formatPrice(value: number): string { - return value.toFixed(2); + isSubmitting = false; } // Handle state change @@ -271,22 +224,19 @@ if (formData.state) { isLoadingCounties = true; - try { - const response = await fetch(`http://localhost:9552/state/${formData.state}`); - if (response.ok) { - counties = await response.json(); - 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'; + + 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'; } - } catch (error) { - errors.countyId = 'Network error loading counties'; - } finally { - isLoadingCounties = false; } + + isLoadingCounties = false; } } diff --git a/src/routes/(app)/vendor/profile/+page.svelte b/src/routes/(app)/vendor/profile/+page.svelte index 94ca13b..569f2d6 100644 --- a/src/routes/(app)/vendor/profile/+page.svelte +++ b/src/routes/(app)/vendor/profile/+page.svelte @@ -1,6 +1,7 @@