feat: implement SEO improvements, listing profiles, service images, and towns serviced

This commit is contained in:
2026-03-08 15:12:55 -04:00
parent 7ac2c7c59e
commit c6ca35fcd9
28 changed files with 4258 additions and 85 deletions

View File

@@ -0,0 +1,2 @@
# Frontend Agent Memory
See main memory at `/mnt/code/tradewar/.claude/agent-memory/svelte-frontend-expert/MEMORY.md`

View File

@@ -1,15 +1,19 @@
FROM node:latest
ENV PUBLIC_BASE_URL=http://localhost:5170
RUN mkdir -p /app
WORKDIR /app
COPY package*.json ./
ENV PATH /app/node_modules/.bin:$PATH
ENV PATH=/app/node_modules/.bin:$PATH
RUN npm install
COPY . /app
COPY . /app
RUN npm run build
EXPOSE 3000
CMD ["node", "build"]

View File

@@ -1,9 +1,11 @@
FROM node:20.11.1 AS builder
ENV PUBLIC_BASE_URL=https://api.auburnoil.com
ARG PUBLIC_API_URL=https://api.localoilprices.com
ENV PUBLIC_API_URL=$PUBLIC_API_URL
WORKDIR /app
COPY package*.json .
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

20
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "0.0.1",
"dependencies": {
"@popperjs/core": "^2.11.8",
"cropperjs": "^1.6.2",
"lucide-svelte": "^0.544.0",
"tailwind-merge": "^1.14.0"
},
@@ -1351,6 +1352,11 @@
"node": ">= 0.6"
}
},
"node_modules/cropperjs": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.6.2.tgz",
"integrity": "sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA=="
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@@ -2152,20 +2158,6 @@
"integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==",
"dev": true
},
"node_modules/picomatch": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true,
"optional": true,
"peer": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pify": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",

View File

@@ -29,6 +29,7 @@
"type": "module",
"dependencies": {
"@popperjs/core": "^2.11.8",
"cropperjs": "^1.6.2",
"lucide-svelte": "^0.544.0",
"tailwind-merge": "^1.14.0"
}

View File

@@ -1,4 +1,6 @@
/* Write your global styles here, in PostCSS syntax */
@import 'cropperjs/dist/cropper.min.css';
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -17,7 +17,13 @@ import type {
ServiceCategory,
StatsPrice,
UpdateUserRequest,
UpdateOilPriceRequest
UpdateOilPriceRequest,
ServiceListing,
CreateServiceListingRequest,
UpdateServiceListingRequest,
CompanyProfile,
Subscription,
Banner
} from './types';
/**
@@ -266,6 +272,12 @@ export const listingsApi = {
*/
async getByCounty(countyId: number): Promise<ApiResponse<Listing[]>> {
return request<Listing[]>(`/listings/county/${countyId}`);
},
/**
* Get a single listing by ID (public)
*/
async getById(id: number): Promise<ApiResponse<Listing>> {
return request<Listing>(`/listings/${id}`);
}
};
@@ -324,6 +336,149 @@ export const categoriesApi = {
}
};
/**
* Image upload API methods
*/
export const uploadApi = {
/** Upload a logo for a fuel listing. Returns { logo_url } on success. */
async uploadListingImage(listingId: number, file: File): Promise<ApiResponse<{ logo_url: string }>> {
const body = new FormData();
body.append('image', file);
return request<{ logo_url: string }>(`/upload/listing/${listingId}/image`, {
method: 'POST',
body: body as unknown as BodyInit,
}, true);
},
async deleteListingImage(listingId: number): Promise<ApiResponse<null>> {
return request<null>(`/upload/listing/${listingId}/image`, { method: 'DELETE' }, true);
},
async uploadServiceListingImage(listingId: number, file: File): Promise<ApiResponse<{ logo_url: string }>> {
const body = new FormData();
body.append('image', file);
return request<{ logo_url: string }>(`/upload/service-listing/${listingId}/image`, {
method: 'POST',
body: body as unknown as BodyInit,
}, true);
},
async deleteServiceListingImage(listingId: number): Promise<ApiResponse<null>> {
return request<null>(`/upload/service-listing/${listingId}/image`, { method: 'DELETE' }, true);
},
async uploadListingBanner(listingId: number, file: File): Promise<ApiResponse<{ banner_url: string }>> {
const body = new FormData();
body.append('image', file);
return request<{ banner_url: string }>(`/upload/listing/${listingId}/banner`, {
method: 'POST',
body: body as unknown as BodyInit,
}, true);
},
async deleteListingBanner(listingId: number): Promise<ApiResponse<null>> {
return request<null>(`/upload/listing/${listingId}/banner`, { method: 'DELETE' }, true);
},
async uploadServiceListingBanner(listingId: number, file: File): Promise<ApiResponse<{ banner_url: string }>> {
const body = new FormData();
body.append('image', file);
return request<{ banner_url: string }>(`/upload/service-listing/${listingId}/banner`, {
method: 'POST',
body: body as unknown as BodyInit,
}, true);
},
async deleteServiceListingBanner(listingId: number): Promise<ApiResponse<null>> {
return request<null>(`/upload/service-listing/${listingId}/banner`, { method: 'DELETE' }, true);
},
};
/**
* Service Listing API methods (for authenticated user's service listings)
*/
export const serviceListingApi = {
async getAll(): Promise<ApiResponse<ServiceListing[]>> {
return request<ServiceListing[]>('/service-listing', {}, true);
},
async getById(id: number): Promise<ApiResponse<ServiceListing>> {
return request<ServiceListing>(`/service-listing/${id}`, {}, true);
},
async create(data: CreateServiceListingRequest): Promise<ApiResponse<ServiceListing>> {
return request<ServiceListing>('/service-listing', {
method: 'POST',
body: JSON.stringify(data),
}, true);
},
async update(id: number, data: UpdateServiceListingRequest): Promise<ApiResponse<ServiceListing>> {
return request<ServiceListing>(`/service-listing/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}, true);
},
async delete(id: number): Promise<ApiResponse<null>> {
return request<null>(`/service-listing/${id}`, {
method: 'DELETE',
}, true);
}
};
/**
* Public service listings API (by county)
*/
export const serviceListingsApi = {
async getByCounty(countyId: number): Promise<ApiResponse<ServiceListing[]>> {
return request<ServiceListing[]>(`/service-listings/county/${countyId}`);
},
async getById(id: number): Promise<ApiResponse<ServiceListing>> {
return request<ServiceListing>(`/service-listings/${id}`);
}
};
/**
* Public company profile API
*/
export const companyProfileApi = {
async getById(companyId: number): Promise<ApiResponse<CompanyProfile>> {
return request<CompanyProfile>(`/company/${companyId}`);
},
async getByUserId(userId: number): Promise<ApiResponse<CompanyProfile>> {
return request<CompanyProfile>(`/company/user/${userId}`);
}
};
/**
* Subscription API (authenticated)
*/
export const subscriptionApi = {
async get(): Promise<ApiResponse<Subscription>> {
return request<Subscription>('/subscription', {}, true);
}
};
/**
* Banner API methods
*/
export const bannerApi = {
/** Public: get active banner */
async getActive(): Promise<ApiResponse<Banner | null>> {
return request<Banner | null>('/banner');
},
/** Admin: create/update banner */
async create(message: string): Promise<ApiResponse<Banner>> {
return request<Banner>('/admin/banner', {
method: 'POST',
body: JSON.stringify({ message }),
}, true);
},
/** Admin: delete (deactivate) a banner */
async delete(id: number): Promise<ApiResponse<null>> {
return request<null>(`/admin/banner/${id}`, {
method: 'DELETE',
}, true);
}
};
/**
* Admin API methods
*/
@@ -399,8 +554,14 @@ export const adminApi = {
export const api = {
auth: authApi,
company: companyApi,
companyProfile: companyProfileApi,
listing: listingApi,
listings: listingsApi,
serviceListing: serviceListingApi,
serviceListings: serviceListingsApi,
subscription: subscriptionApi,
banner: bannerApi,
upload: uploadApi,
oilPrices: oilPricesApi,
state: stateApi,
categories: categoriesApi,

View File

@@ -1,3 +1,3 @@
// API Client exports
export { api, authApi, companyApi, listingApi, listingsApi, oilPricesApi, stateApi, categoriesApi } from './client';
export { api, authApi, companyApi, companyProfileApi, listingApi, listingsApi, serviceListingApi, serviceListingsApi, subscriptionApi, bannerApi, uploadApi, oilPricesApi, stateApi, categoriesApi } from './client';
export * from './types';

View File

@@ -78,6 +78,12 @@ export interface Listing {
county_id: number;
town: string | null;
url: string | null;
logo_url: string | null;
banner_url: string | null;
facebook_url: string | null;
instagram_url: string | null;
google_business_url: string | null;
towns_serviced?: string[] | null;
user_id: number;
created_at?: string;
last_edited?: string;
@@ -97,6 +103,10 @@ export interface CreateListingRequest {
county_id: number;
town?: string | null;
url?: string | null;
facebook_url?: string | null;
instagram_url?: string | null;
google_business_url?: string | null;
towns_serviced?: string[] | null;
}
export type UpdateListingRequest = Partial<CreateListingRequest>;
@@ -148,5 +158,78 @@ export interface UpdateOilPriceRequest {
name?: string;
url?: string;
phone?: string;
}
// Service Listing Types (boiler/HVAC service companies)
export interface ServiceListing {
id: number;
company_name: string;
is_active: boolean;
twenty_four_hour: boolean;
emergency_service: boolean;
town: string | null;
county_id: number;
phone: string | null;
website: string | null;
email: string | null;
description: string | null;
licensed_insured: boolean;
service_area: string | null;
years_experience: number | null;
user_id: number;
last_edited?: string;
logo_url: string | null;
banner_url: string | null;
facebook_url: string | null;
instagram_url: string | null;
google_business_url: string | null;
towns_serviced?: string[] | null;
}
export interface CreateServiceListingRequest {
company_name: string;
is_active?: boolean;
twenty_four_hour?: boolean;
emergency_service?: boolean;
town?: string | null;
county_id: number;
phone?: string | null;
website?: string | null;
email?: string | null;
description?: string | null;
licensed_insured?: boolean;
service_area?: string | null;
years_experience?: number | null;
facebook_url?: string | null;
instagram_url?: string | null;
google_business_url?: string | null;
towns_serviced?: string[] | null;
}
export type UpdateServiceListingRequest = Partial<CreateServiceListingRequest>;
// Company Profile (public-facing)
export interface CompanyProfile {
company: Company;
fuel_listings: Listing[];
service_listings: ServiceListing[];
}
// Subscription Types
export interface Subscription {
id: number;
company_id: number;
trial_start: string;
trial_end: string;
status: string;
plan: string | null;
created_at?: string;
}
// Banner Types (admin-managed site-wide banners)
export interface Banner {
id: number;
message: string;
is_active: boolean;
created_at?: string;
}

View File

@@ -0,0 +1,88 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
export let open = false;
export let imageSrc = '';
const dispatch = createEventDispatcher<{
crop: Blob;
cancel: void;
}>();
let cropperInstance: import('cropperjs').default | null = null;
let isProcessing = false;
// Svelte action — only runs in the browser, so dynamic import is safe here
function initCropper(node: HTMLImageElement) {
import('cropperjs').then(({ default: Cropper }) => {
cropperInstance = new Cropper(node, {
aspectRatio: 3,
viewMode: 0, // no restriction — canvas can move freely
dragMode: 'move', // drag moves the image, not the crop box
cropBoxMovable: false,
cropBoxResizable: false,
toggleDragModeOnDblclick: false,
autoCropArea: 0.8, // crop box = 80% so image extends beyond it and can be dragged
});
});
return {
destroy() {
cropperInstance?.destroy();
cropperInstance = null;
}
};
}
function handleApply() {
if (!cropperInstance) return;
isProcessing = true;
const canvas = cropperInstance.getCroppedCanvas({ width: 750, height: 250 });
canvas.toBlob(
(blob) => {
if (blob) dispatch('crop', blob);
isProcessing = false;
},
'image/jpeg',
0.92
);
}
function handleCancel() {
dispatch('cancel');
}
</script>
{#if open}
<div class="modal modal-open z-50">
<div class="modal-box max-w-2xl w-full">
<h3 class="font-bold text-lg mb-1">Crop Banner</h3>
<p class="text-sm text-base-content/50 mb-4">Drag to reposition &bull; Scroll or pinch to zoom</p>
<div class="w-full h-80 bg-base-300 rounded-lg overflow-hidden">
<img
src={imageSrc}
alt="Crop preview"
use:initCropper
class="block max-w-full"
/>
</div>
<div class="modal-action mt-6">
<button class="btn btn-ghost" on:click={handleCancel} disabled={isProcessing}>
Cancel
</button>
<button class="btn btn-primary" on:click={handleApply} disabled={isProcessing}>
{#if isProcessing}
<span class="loading loading-spinner loading-sm"></span>
Processing...
{:else}
Apply Crop
{/if}
</button>
</div>
</div>
<div class="modal-backdrop bg-black/60" on:click={handleCancel} role="presentation"></div>
</div>
{/if}

View File

@@ -0,0 +1,88 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
export let open = false;
export let imageSrc = '';
const dispatch = createEventDispatcher<{
crop: Blob;
cancel: void;
}>();
let cropperInstance: import('cropperjs').default | null = null;
let isProcessing = false;
// Svelte action — only runs in the browser, so dynamic import is safe here
function initCropper(node: HTMLImageElement) {
import('cropperjs').then(({ default: Cropper }) => {
cropperInstance = new Cropper(node, {
aspectRatio: 1,
viewMode: 0, // no restriction — canvas can move freely
dragMode: 'move', // drag moves the image, not the crop box
cropBoxMovable: false,
cropBoxResizable: false,
toggleDragModeOnDblclick: false,
autoCropArea: 0.8, // crop box = 80% so image extends beyond it and can be dragged
});
});
return {
destroy() {
cropperInstance?.destroy();
cropperInstance = null;
}
};
}
function handleApply() {
if (!cropperInstance) return;
isProcessing = true;
const canvas = cropperInstance.getCroppedCanvas({ width: 500, height: 500 });
canvas.toBlob(
(blob) => {
if (blob) dispatch('crop', blob);
isProcessing = false;
},
'image/jpeg',
0.92
);
}
function handleCancel() {
dispatch('cancel');
}
</script>
{#if open}
<div class="modal modal-open z-50">
<div class="modal-box max-w-2xl w-full">
<h3 class="font-bold text-lg mb-1">Crop Logo</h3>
<p class="text-sm text-base-content/50 mb-4">Drag to reposition &bull; Scroll or pinch to zoom</p>
<div class="w-full h-80 bg-base-300 rounded-lg overflow-hidden">
<img
src={imageSrc}
alt="Crop preview"
use:initCropper
class="block max-w-full"
/>
</div>
<div class="modal-action mt-6">
<button class="btn btn-ghost" on:click={handleCancel} disabled={isProcessing}>
Cancel
</button>
<button class="btn btn-primary" on:click={handleApply} disabled={isProcessing}>
{#if isProcessing}
<span class="loading loading-spinner loading-sm"></span>
Processing...
{:else}
Apply Crop
{/if}
</button>
</div>
</div>
<div class="modal-backdrop bg-black/60" on:click={handleCancel} role="presentation"></div>
</div>
{/if}

View File

@@ -4,7 +4,10 @@
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';
import { authApi, bannerApi } from '$lib/api';
import type { Banner } from '$lib/api';
let activeBanner: Banner | null = null;
// Initialize dark mode on mount to ensure data-theme is set
onMount(() => {
@@ -16,6 +19,14 @@
}
});
// Fetch active banner
onMount(async () => {
const result = await bannerApi.getActive();
if (!result.error && result.data) {
activeBanner = result.data;
}
});
// Placeholder for user store - in a real app, this would be managed by an auth library or context
let storedUser: User | null = null;
@@ -56,7 +67,7 @@
<div class="min-h-screen flex flex-col">
<header class="navbar bg-primary text-primary-content shadow-lg">
<div class="flex-1">
<a href="/" class="btn btn-ghost normal-case text-xl">Biz Hero</a>
<a href="/" class="btn btn-ghost normal-case text-xl">LocalOilPrices</a>
</div>
<div class="flex-none flex items-center gap-2">
<button type="button" class="btn btn-ghost" on:click={toggleDarkMode}>
@@ -89,13 +100,24 @@
</div>
</header>
<!-- Admin Banner -->
{#if activeBanner}
<div class="flex justify-center px-4 pt-3">
<div class="flex items-center gap-3 px-4 py-3 rounded-xl shadow-lg max-w-2xl w-full bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700/40 text-amber-700 dark:text-amber-300">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-5 w-5" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<span class="text-sm flex-1">{activeBanner.message}</span>
</div>
</div>
{/if}
<main class="flex-grow container mx-auto p-4">
<slot />
</main>
<footer class="footer footer-center p-4 bg-base-300 text-base-content">
<div>
<p>Copyright © {new Date().getFullYear()} - All right reserved</p>
<p>Copyright © {new Date().getFullYear()} Rocket Services LLC - All rights reserved</p>
<p class="text-sm text-primary font-semibold">🔥 1 Year Free Trial Per Account!</p>
{#if !$user}
<a href="/login" class="link link-primary">Vendor Login</a>
{/if}

View File

@@ -1,6 +1,7 @@
<!-- src/routes/(app)/+page.svelte -->
<script lang="ts">
import { newEnglandStates, mapViewBox, user } from '$lib/states';
import { PUBLIC_API_URL } from '$env/static/public';
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { api } from '$lib/api';
@@ -15,6 +16,7 @@
Flame,
ChevronRight,
ArrowDown,
Wrench,
} from 'lucide-svelte';
let hoveredState: string | null = null;
@@ -88,9 +90,9 @@
description: 'See heating oil and biofuel prices from local dealers side by side.',
},
{
icon: MapPin,
title: 'Find Local Dealers',
description: 'Discover trusted fuel dealers in your county and neighborhood.',
icon: Wrench,
title: 'Find Service Companies',
description: 'Locate boiler and furnace repair companies near you — including 24-hour emergency service.',
},
{
icon: DollarSign,
@@ -100,14 +102,14 @@
{
icon: ThumbsUp,
title: 'Always Free',
description: 'No sign-up, no fees. Just honest price comparisons for homeowners.',
description: 'No sign-up, no fees. Honest price comparisons and service directories for homeowners.',
},
];
</script>
<svelte:head>
<title>Biz Hero - Compare Heating Oil Prices in New England</title>
<meta name="description" content="Compare heating oil and biofuel prices across all six New England states. Find the best deals from local dealers in your county." />
<title>LocalOilPrices - Heating Oil Prices &amp; Service Companies in New England</title>
<meta name="description" content="Compare heating oil and biofuel prices across all six New England states. Find the best deals from local dealers and locate boiler service companies in your county." />
</svelte:head>
<!-- ============================================================ -->
@@ -128,13 +130,13 @@
<h1 class="text-3xl sm:text-4xl md:text-5xl font-bold leading-tight text-base-content mb-4 px-2">
Find the <span class="text-primary">Best Heating Oil Prices</span>
<br class="hidden sm:block" />
in New England
&amp; Service Companies in New England
</h1>
<!-- Subheadline -->
<p class="text-lg sm:text-xl text-base-content/70 max-w-2xl mx-auto mb-6 px-4 leading-relaxed">
Compare prices from local dealers across all six states.
<span class="hidden sm:inline">Save money on heating oil and biofuel this season.</span>
<span class="hidden sm:inline">Find boiler &amp; furnace service companies — including emergency no-heat response.</span>
</p>
<!-- CTA hint -->
@@ -279,10 +281,10 @@
{#if valueVisible}
<div in:fly={{ y: 20, duration: 400 }}>
<h2 class="text-2xl sm:text-3xl font-bold text-center text-base-content mb-2">
Why Use Biz Hero?
Why Use LocalOilPrices?
</h2>
<p class="text-base text-base-content/60 text-center mb-8 sm:mb-10">
Helping New England homeowners make smarter heating decisions
Helping New England homeowners find the best oil prices and trusted service companies
</p>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 max-w-5xl mx-auto">
@@ -314,9 +316,9 @@
<!-- ============================================================ -->
{#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">
<div class="inline-flex flex-col items-center gap-3 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 vendor?
Are you a heating oil or service company?
</p>
<a
href="/login"
@@ -328,3 +330,18 @@
</div>
</section>
{/if}
<!-- ============================================================ -->
<!-- DEVELOPER API LINK -->
<!-- ============================================================ -->
<section class="pb-6 text-center">
<a
href="{PUBLIC_API_URL}/api-directory"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-base-content/50 hover:text-primary transition-colors inline-flex items-center gap-1 font-medium"
>
Developer API Directory
<ChevronRight size={14} />
</a>
</section>

View File

@@ -173,10 +173,10 @@
<!-- SEO -->
<svelte:head>
{#if stateData}
<title>Heating Oil Prices in {stateData.name} | Biz Hero</title>
<meta name="description" content="Compare heating oil and biofuel prices across all {stateData.name} counties. Find the best deals from local dealers near you." />
<title>Heating Oil Prices & Service Companies in {stateData.name} | LocalOilPrices</title>
<meta name="description" content="Compare heating oil prices and find local service companies across all {stateData.name} counties. Need heat? Find fuel dealers and HVAC service providers near you." />
{:else}
<title>State Not Found | Biz Hero</title>
<title>State Not Found | LocalOilPrices</title>
{/if}
</svelte:head>
@@ -222,7 +222,9 @@
<!-- 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
Explore heating oil prices & local service companies by county.
<br/>
<span class="text-base text-base-content/50">No heat? Find fuel dealers and emergency service providers near you.</span>
{#if statePrice}
<br/>
<span class="inline-block mt-2 px-3 py-1 bg-base-200 rounded-full text-base font-semibold text-primary">
@@ -307,7 +309,7 @@
Counties in {stateData.name}
</h2>
<p class="text-base text-base-content/60 text-center mb-6 sm:mb-8">
Select a county to view local heating oil dealers and prices
Select a county to view local heating oil dealers, prices, and service companies
</p>
{#if loading}
@@ -365,7 +367,7 @@
<span class="block text-sm sm:text-base font-semibold text-base-content truncate leading-tight">
{county.name}
</span>
<span class="text-xs text-base-content/40">View prices</span>
<span class="text-xs text-base-content/40">View prices & services</span>
</div>
<ChevronRight

View File

@@ -3,8 +3,9 @@
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { newEnglandStates } from '../../../../lib/states';
import { PUBLIC_API_URL } from '$env/static/public';
import { api } from '$lib/api';
import type { Listing, OilPrice, County } from '$lib/api';
import type { Listing, OilPrice, County, ServiceListing } from '$lib/api';
import { fade, fly } from 'svelte/transition';
import {
ChevronLeft,
@@ -26,6 +27,7 @@
let countyData: County | null = null;
let listings: Listing[] = [];
let oilPrices: OilPrice[] = [];
let serviceListings: ServiceListing[] = [];
let loading = true;
let listingsLoading = false;
let error: string | null = null;
@@ -33,6 +35,9 @@
let sortColumn = 'price_per_gallon';
let sortDirection = 'asc';
// Active tab: 'fuel' or 'service'
let activeTab: 'fuel' | 'service' = 'fuel';
// Market Prices sorting
let marketSortColumn = 'date';
let marketSortDirection = 'desc';
@@ -67,9 +72,10 @@
listingsLoading = true;
listingsError = null;
const [listingsResult, oilPricesResult] = await Promise.all([
const [listingsResult, oilPricesResult, serviceResult] = await Promise.all([
api.listings.getByCounty(countyData.id),
api.oilPrices.getByCounty(countyData.id)
api.oilPrices.getByCounty(countyData.id),
api.serviceListings.getByCounty(countyData.id)
]);
if (listingsResult.error) {
@@ -83,6 +89,9 @@
oilPrices = (oilPricesResult.data || []).filter(p => p.price !== 0);
sortMarketPrices(); // Sort immediately after fetching
// Service listings failure is non-critical
serviceListings = serviceResult.data || [];
listingsLoading = false;
}
@@ -226,10 +235,10 @@
<!-- SEO -->
<svelte:head>
{#if countyData}
<title>Heating Oil Prices in {countyData.name}, {getStateName(countyData.state)} | Biz Hero</title>
<title>Heating Oil Prices in {countyData.name}, {getStateName(countyData.state)} | LocalOilPrices</title>
<meta name="description" content="Compare heating oil and biofuel prices from local dealers in {countyData.name}, {getStateName(countyData.state)}. Find the best deals today." />
{:else}
<title>County Not Found | Biz Hero</title>
<title>County Not Found | LocalOilPrices</title>
{/if}
</svelte:head>
@@ -316,15 +325,46 @@
<!-- Subtitle -->
<p class="text-base text-base-content/50 max-w-xl mx-auto leading-relaxed px-4">
Compare heating oil prices from local dealers
Compare heating oil prices and find local service companies
</p>
</section>
{/if}
<!-- ============================================================ -->
<!-- FUEL / SERVICE TABS -->
<!-- ============================================================ -->
{#if headerVisible}
<div class="flex justify-center mb-2 px-2" in:fade={{ duration: 300 }}>
<div class="tabs tabs-boxed bg-base-200/70 gap-1 p-1">
<button
class="tab gap-2 font-semibold {activeTab === 'fuel' ? 'tab-active' : ''}"
on:click={() => { activeTab = 'fuel'; }}
>
<Flame size={16} />
Fuel Prices
{#if listings.filter(l => l.phone).length > 0}
<span class="badge badge-sm badge-primary">{listings.filter(l => l.phone).length}</span>
{/if}
</button>
<button
class="tab gap-2 font-semibold {activeTab === 'service' ? 'tab-active' : ''}"
on:click={() => { activeTab = 'service'; }}
>
<Wrench size={16} />
Service Companies
{#if serviceListings.length > 0}
<span class="badge badge-sm badge-success">{serviceListings.length}</span>
{/if}
</button>
</div>
</div>
{/if}
<!-- ============================================================ -->
<!-- LISTINGS CONTENT -->
<!-- ============================================================ -->
{#if activeTab === 'fuel'}
{#if listingsLoading}
<!-- Skeleton loading for listings -->
<div class="px-2 mt-4">
@@ -520,16 +560,25 @@
>
<!-- 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
<div class="flex gap-3 items-center">
<div class="w-16 h-16 rounded-full overflow-hidden bg-primary flex items-center justify-center flex-shrink-0">
{#if listing.logo_url}
<img src="{PUBLIC_API_URL}{listing.logo_url}" alt="" class="w-full h-full object-cover" />
{:else}
<span class="text-xl text-primary-content font-bold">{listing.company_name.charAt(0).toUpperCase()}</span>
{/if}
</div>
<div class="flex flex-col">
<a href="/listing/{listing.id}" class="text-base font-semibold text-base-content hover:text-primary hover:underline transition-colors">
{listing.company_name}
</a>
{/if}
{#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>
</div>
</td>
@@ -588,9 +637,13 @@
{:else}
<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} />
{formatOnlineOrdering(listing.online_ordering)}
<div class="text-xs flex items-center gap-1">
<Globe size={11} class="text-base-content/40" />
{#if (listing.online_ordering === 'both' || listing.online_ordering === 'online_only') && listing.url}
<a href={listing.url} target="_blank" rel="noopener noreferrer" class="font-medium text-primary hover:underline">Order online !</a>
{:else}
<span class="text-base-content/40">{formatOnlineOrdering(listing.online_ordering)}</span>
{/if}
</div>
</div>
</td>
@@ -646,12 +699,27 @@
<div class="lg:hidden space-y-3">
{#each listings.filter(l => l.phone) as listing, i}
<div class="listing-card stagger-{(i % 6) + 1} opacity-0 animate-fade-in-up">
{#if listing.banner_url}
<div class="mb-4 block lg:hidden w-full max-w-[400px] mx-auto aspect-[3/1] rounded-lg overflow-hidden bg-base-300">
<img src="{PUBLIC_API_URL}{listing.banner_url}" alt="{listing.company_name} banner" class="w-full h-full object-cover" />
</div>
{/if}
<!-- Top row: Company + Price -->
<div class="flex items-start justify-between gap-3 mb-3">
<div class="min-w-0 flex-1">
<h3 class="text-lg font-bold text-base-content leading-tight truncate">
{listing.company_name}
</h3>
<div class="flex gap-3 min-w-0 flex-1">
<div class="w-16 h-16 rounded-full overflow-hidden bg-primary flex items-center justify-center flex-shrink-0 mt-0.5">
{#if listing.logo_url}
<img src="{PUBLIC_API_URL}{listing.logo_url}" alt="" class="w-full h-full object-cover" />
{:else}
<span class="text-2xl text-primary-content font-bold">{listing.company_name.charAt(0).toUpperCase()}</span>
{/if}
</div>
<div class="min-w-0">
<h3 class="text-lg font-bold leading-tight truncate">
<a href="/listing/{listing.id}" class="text-base-content hover:text-primary hover:underline transition-colors">
{listing.company_name}
</a>
</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} />
@@ -664,6 +732,7 @@
{listing.town}
</p>
{/if}
</div>
</div>
<div class="text-right flex-shrink-0">
<div class="price-hero !text-2xl">
@@ -707,7 +776,11 @@
<div class="flex items-center gap-1.5">
<Globe size={14} class="text-base-content/40 flex-shrink-0" />
<span class="text-base-content/60">Order:</span>
<span class="font-medium text-base-content">{formatOnlineOrdering(listing.online_ordering)}</span>
{#if (listing.online_ordering === 'both' || listing.online_ordering === 'online_only') && listing.url}
<a href={listing.url} target="_blank" rel="noopener noreferrer" class="font-medium text-primary hover:underline">Order online !</a>
{:else}
<span class="font-medium text-base-content">{formatOnlineOrdering(listing.online_ordering)}</span>
{/if}
</div>
<!-- Last updated -->
@@ -930,6 +1003,210 @@
{/if}
<!-- Close fuel tab -->
{:else if activeTab === 'service'}
<!-- ============================================================ -->
<!-- SERVICE COMPANIES TAB -->
<!-- ============================================================ -->
<section class="px-2 mt-4">
<div class="price-section">
<div class="mb-4">
<h2 class="text-2xl sm:text-3xl font-bold text-base-content flex items-center gap-2">
<div class="value-icon !w-9 !h-9 sm:!w-10 sm:!h-10 flex-shrink-0">
<Wrench size={20} />
</div>
Service Companies
</h2>
<p class="text-base text-base-content/50 mt-1">
Local heating oil service &amp; boiler repair companies
</p>
</div>
{#if listingsLoading}
<div class="space-y-3">
{#each Array(3) as _, i}
<div class="skeleton-box h-24 stagger-{(i % 6) + 1} opacity-0 animate-fade-in"></div>
{/each}
</div>
{:else if serviceListings.length === 0}
<div class="text-center py-10">
<div class="value-icon mx-auto mb-4">
<Wrench size={28} />
</div>
<p class="text-lg font-semibold text-base-content mb-1">No service companies listed yet</p>
<p class="text-base text-base-content/50">Check back soon for local service providers.</p>
</div>
{:else}
<!-- Desktop Table -->
<div class="hidden lg:block">
<div class="price-table-wrap">
<table class="w-full">
<thead>
<tr class="border-b border-base-300 bg-base-200/50">
<th class="text-left px-4 py-3 text-sm font-semibold text-base-content/70">Company</th>
<th class="text-left px-4 py-3 text-sm font-semibold text-base-content/70">Location</th>
<th class="text-left px-4 py-3 text-sm font-semibold text-base-content/70">Services</th>
<th class="text-left px-4 py-3 text-sm font-semibold text-base-content/70">Contact</th>
</tr>
</thead>
<tbody>
{#each serviceListings as sl, i}
<tr class="border-b border-base-300/40 hover:bg-base-200/30 transition-colors">
<td class="px-4 py-4">
<div class="flex gap-3 items-start">
<div class="w-16 h-16 rounded-full overflow-hidden bg-success flex items-center justify-center flex-shrink-0 mt-0.5">
{#if sl.logo_url}
<img src="{PUBLIC_API_URL}{sl.logo_url}" alt="" class="w-full h-full object-cover" />
{:else}
<span class="text-xl text-success-content font-bold">{sl.company_name.charAt(0).toUpperCase()}</span>
{/if}
</div>
<div>
<a href="/service/{sl.id}" class="font-semibold text-base-content hover:text-success hover:underline transition-colors">{sl.company_name}</a>
{#if sl.years_experience}
<div class="text-xs text-base-content/50 mt-0.5">{sl.years_experience} yrs experience</div>
{/if}
{#if sl.description}
<div class="text-sm text-base-content/60 mt-1 max-w-xs">{sl.description}</div>
{/if}
</div>
</div>
</td>
<td class="px-4 py-4">
{#if sl.town}
<div class="flex items-center gap-1.5 text-sm">
<MapPin size={13} class="text-base-content/40 flex-shrink-0" />
<span>{sl.town}</span>
</div>
{/if}
{#if sl.service_area}
<div class="text-xs text-base-content/50 mt-0.5">{sl.service_area}</div>
{/if}
</td>
<td class="px-4 py-4">
<div class="flex flex-wrap gap-1.5">
{#if sl.twenty_four_hour}
<span class="badge badge-success badge-sm gap-1">
<Clock size={10} />
24hr
</span>
{/if}
{#if sl.emergency_service}
<span class="badge badge-warning badge-sm">Emergency</span>
{/if}
{#if sl.licensed_insured}
<span class="badge badge-info badge-sm">Licensed</span>
{/if}
</div>
</td>
<td class="px-4 py-4">
<div class="flex flex-col gap-1.5">
{#if sl.phone}
<a href="tel:{sl.phone}" class="inline-flex items-center gap-1.5 text-sm text-primary hover:underline">
<Phone size={13} />
{sl.phone}
</a>
{/if}
{#if sl.website}
<a href={sl.website} target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-1.5 text-sm text-primary hover:underline">
<Globe size={13} />
Website
</a>
{/if}
{#if sl.email}
<a href="mailto:{sl.email}" class="inline-flex items-center gap-1.5 text-sm text-primary hover:underline">
<Info size={13} />
{sl.email}
</a>
{/if}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
<!-- Mobile Cards -->
<div class="lg:hidden space-y-3">
{#each serviceListings as sl, i}
<div
class="price-card stagger-{(i % 6) + 1} opacity-0 animate-fade-in"
in:fly={{ y: 10, duration: 250, delay: i * 50 }}
>
<div class="flex flex-col gap-3">
{#if sl.banner_url}
<div class="mb-3 block lg:hidden w-full max-w-[400px] mx-auto aspect-[3/1] rounded-lg overflow-hidden bg-base-300">
<img src="{PUBLIC_API_URL}{sl.banner_url}" alt="{sl.company_name} banner" class="w-full h-full object-cover" />
</div>
{/if}
<div class="flex gap-3 items-center">
<div class="w-16 h-16 rounded-full overflow-hidden bg-success flex items-center justify-center flex-shrink-0">
{#if sl.logo_url}
<img src="{PUBLIC_API_URL}{sl.logo_url}" alt="" class="w-full h-full object-cover" />
{:else}
<span class="text-2xl text-success-content font-bold">{sl.company_name.charAt(0).toUpperCase()}</span>
{/if}
</div>
<div>
<a href="/service/{sl.id}" class="font-semibold text-base-content text-lg hover:text-success hover:underline transition-colors">{sl.company_name}</a>
{#if sl.description}
<div class="text-sm text-base-content/60 mt-1 line-clamp-2">{sl.description}</div>
{/if}
</div>
</div>
<div class="flex flex-wrap gap-1.5">
{#if sl.twenty_four_hour}
<span class="badge badge-success badge-sm gap-1"><Clock size={10} />24hr</span>
{/if}
{#if sl.emergency_service}
<span class="badge badge-warning badge-sm">Emergency</span>
{/if}
{#if sl.licensed_insured}
<span class="badge badge-info badge-sm">Licensed</span>
{/if}
</div>
<div class="grid grid-cols-2 gap-2 text-sm">
{#if sl.town}
<div class="flex items-center gap-1.5 text-base-content/70">
<MapPin size={13} class="flex-shrink-0" />
{sl.town}
</div>
{/if}
{#if sl.years_experience}
<div class="text-base-content/50">{sl.years_experience} yrs</div>
{/if}
</div>
<div class="flex flex-wrap gap-3">
{#if sl.phone}
<a href="tel:{sl.phone}" class="btn btn-sm btn-outline gap-1.5">
<Phone size={14} />
{sl.phone}
</a>
{/if}
{#if sl.website}
<a href={sl.website} target="_blank" rel="noopener noreferrer" class="btn btn-sm btn-outline gap-1.5">
<Globe size={14} />
Website
</a>
{/if}
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
</section>
{/if}
<!-- End tab conditional -->
<!-- ============================================================ -->
<!-- BACK NAVIGATION (subtle, bottom) -->

View File

@@ -13,6 +13,7 @@
UpdateCompanyRequest,
UpdateListingRequest,
UpdateOilPriceRequest,
Banner,
} from "$lib/api/types";
let activeTab = "listings";
@@ -20,6 +21,12 @@
let loading = false;
let error: string | null = null;
// Banner management state
let currentBanner: Banner | null = null;
let newBannerMessage = '';
let bannerLoading = false;
let bannerSuccess = '';
const tabs = [
{ id: "listings", label: "Listings" },
{ id: "companies", label: "Companies" },
@@ -105,8 +112,45 @@
return;
}
await loadData();
await loadBanner();
});
async function loadBanner() {
bannerLoading = true;
const result = await api.banner.getActive();
if (!result.error) {
currentBanner = result.data || null;
}
bannerLoading = false;
}
async function createBanner() {
if (!newBannerMessage.trim()) return;
bannerLoading = true;
bannerSuccess = '';
const result = await api.banner.create(newBannerMessage.trim());
if (result.data) {
currentBanner = result.data;
newBannerMessage = '';
bannerSuccess = 'Banner created!';
setTimeout(() => { bannerSuccess = ''; }, 3000);
}
bannerLoading = false;
}
async function deactivateBanner() {
if (!currentBanner) return;
bannerLoading = true;
bannerSuccess = '';
const result = await api.banner.delete(currentBanner.id);
if (!result.error) {
currentBanner = null;
bannerSuccess = 'Banner deactivated!';
setTimeout(() => { bannerSuccess = ''; }, 3000);
}
bannerLoading = false;
}
async function loadData() {
loading = true;
error = null;
@@ -215,6 +259,56 @@
</div>
{/if}
<!-- Banner Management -->
<div class="card bg-base-100 shadow-md border border-warning/30 mb-6">
<div class="card-body p-5">
<h2 class="text-lg font-bold mb-3 flex items-center gap-2">
⚠️ Site Banner
{#if currentBanner}
<span class="badge badge-warning">Active</span>
{:else}
<span class="badge badge-neutral">None</span>
{/if}
</h2>
{#if bannerSuccess}
<div class="alert alert-success mb-3 py-2">
<span>{bannerSuccess}</span>
</div>
{/if}
{#if currentBanner}
<div class="bg-warning/10 border border-warning/30 rounded-lg p-3 mb-3">
<p class="text-sm font-medium">{currentBanner.message}</p>
<p class="text-xs text-base-content/40 mt-1">Created: {new Date(currentBanner.created_at || '').toLocaleString()}</p>
</div>
<button
class="btn btn-error btn-sm"
on:click={deactivateBanner}
disabled={bannerLoading}
>
{bannerLoading ? 'Deactivating...' : 'Deactivate Banner'}
</button>
{/if}
<div class="flex gap-2 mt-2">
<input
type="text"
bind:value={newBannerMessage}
placeholder="Enter new banner message..."
class="input input-bordered flex-1 input-sm"
/>
<button
class="btn btn-warning btn-sm"
on:click={createBanner}
disabled={bannerLoading || !newBannerMessage.trim()}
>
{bannerLoading ? 'Saving...' : currentBanner ? 'Replace Banner' : 'Create Banner'}
</button>
</div>
</div>
</div>
<div class="tabs tabs-boxed mb-4">
{#each tabs as tab}
<button

View File

@@ -0,0 +1,364 @@
<!-- src/routes/(app)/company/[id]/+page.svelte -->
<script lang="ts">
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { PUBLIC_API_URL } from '$env/static/public';
import type { CompanyProfile, Listing, ServiceListing } from '$lib/api';
import { fade, fly } from 'svelte/transition';
import {
ChevronLeft,
Phone,
Mail,
MapPin,
Globe,
DollarSign,
Wrench,
Droplets,
Clock,
Building2,
User,
Calendar,
Shield,
Flame,
AlertCircle,
} from 'lucide-svelte';
const { id } = $page.params as { id: string };
let profile: CompanyProfile | null = null;
let loading = true;
let error: string | null = null;
// Section reveals
let headerVisible = false;
let contentVisible = false;
onMount(async () => {
const result = await api.companyProfile.getById(parseInt(id));
if (result.error) {
error = result.error;
} else {
profile = result.data;
}
loading = false;
headerVisible = true;
setTimeout(() => { contentVisible = true; }, 250);
});
function formatDate(dateStr: string | undefined | null): string {
if (!dateStr) return 'N/A';
try {
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
} catch {
return dateStr;
}
}
function formatPhone(phone: string): string {
const cleaned = phone.replace(/\D/g, '');
if (cleaned.length === 10) {
return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6)}`;
} else if (cleaned.length === 11 && cleaned.startsWith('1')) {
return `+1 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7)}`;
}
return phone;
}
</script>
<!-- SEO -->
<svelte:head>
{#if profile}
<title>{profile.company.name} | LocalOilPrices</title>
<meta name="description" content="View company profile for {profile.company.name}. Find heating oil prices, service offerings, and contact information." />
{:else}
<title>Company Profile | LocalOilPrices</title>
{/if}
</svelte:head>
{#if loading}
<!-- Loading -->
<div class="py-10 px-2">
<div class="skeleton-box h-4 w-48 mb-6"></div>
<div class="skeleton-box h-40 w-full max-w-3xl mx-auto mb-6 rounded-xl"></div>
<div class="skeleton-box h-8 w-64 mx-auto mb-4"></div>
<div class="skeleton-box h-5 w-80 mx-auto"></div>
</div>
{:else if error || !profile}
<!-- Error -->
<div class="text-center py-16">
<AlertCircle size={48} class="mx-auto text-error/50 mb-4" />
<h1 class="text-3xl font-bold text-error mb-3">Company Not Found</h1>
<p class="text-base-content/60 text-lg mb-6">
{error || 'Could not find this company.'}
</p>
<a href="/" class="btn btn-primary gap-1">
<ChevronLeft size={16} />
Back to Home
</a>
</div>
{:else}
<!-- Breadcrumb -->
<nav class="mb-6 px-2" aria-label="Breadcrumb">
<ol class="flex items-center gap-1 text-sm">
<li>
<a href="/" class="inline-flex items-center gap-1 text-primary hover:underline focus:outline-none focus:ring-2 focus:ring-primary rounded">
<ChevronLeft size={14} />
<span>Home</span>
</a>
</li>
<li class="text-base-content/40" aria-hidden="true">/</li>
<li class="font-medium text-base-content" aria-current="page">{profile.company.name}</li>
</ol>
</nav>
<!-- Company Profile Header -->
{#if headerVisible}
<section class="px-2 mb-8" in:fly={{ y: 24, duration: 500 }}>
<!-- Banner / Cover area -->
<div class="relative rounded-2xl overflow-hidden bg-gradient-to-r from-primary/20 via-primary/10 to-accent/20 h-32 sm:h-44 md:h-52 mb-4">
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-base-100/50"></div>
</div>
<!-- Avatar & Name overlay -->
<div class="flex flex-col sm:flex-row items-center sm:items-end gap-4 -mt-14 sm:-mt-16 px-4 sm:px-8">
<!-- Avatar -->
<div class="w-24 h-24 sm:w-28 sm:h-28 rounded-full bg-primary flex items-center justify-center border-4 border-base-100 shadow-lg flex-shrink-0 z-10">
<span class="text-4xl sm:text-5xl text-primary-content font-bold">
{profile.company.name.charAt(0).toUpperCase()}
</span>
</div>
<!-- Name & meta -->
<div class="text-center sm:text-left flex-1 pb-2">
<h1 class="text-2xl sm:text-3xl md:text-4xl font-bold text-base-content leading-tight">
{profile.company.name}
</h1>
{#if profile.company.town && profile.company.state}
<p class="text-base text-base-content/60 flex items-center gap-1 mt-1 justify-center sm:justify-start">
<MapPin size={14} />
{profile.company.town}, {profile.company.state}
</p>
{/if}
<p class="text-sm text-base-content/40 flex items-center gap-1 mt-0.5 justify-center sm:justify-start">
<Calendar size={13} />
Member since {formatDate(profile.company.created)}
</p>
</div>
<!-- Contact button -->
{#if profile.company.phone}
<a
href="tel:{profile.company.phone}"
class="btn btn-primary gap-2 flex-shrink-0"
>
<Phone size={16} />
Call {formatPhone(profile.company.phone)}
</a>
{/if}
</div>
</section>
{/if}
<!-- Content -->
{#if contentVisible}
<div class="px-2 pb-10" in:fade={{ duration: 400 }}>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 max-w-6xl mx-auto">
<!-- Left sidebar — Company Info -->
<div class="lg:col-span-1 space-y-4">
<!-- About Card -->
<div class="card bg-base-100 shadow-md border border-base-300/50">
<div class="card-body p-5">
<h2 class="text-lg font-bold text-base-content flex items-center gap-2 mb-3">
<Building2 size={18} class="text-primary" />
About
</h2>
<div class="space-y-3 text-sm">
{#if profile.company.address}
<div class="flex items-start gap-2">
<MapPin size={14} class="text-base-content/40 mt-0.5 flex-shrink-0" />
<span class="text-base-content/70">{profile.company.address}</span>
</div>
{/if}
{#if profile.company.town && profile.company.state}
<div class="flex items-start gap-2">
<MapPin size={14} class="text-base-content/40 mt-0.5 flex-shrink-0" />
<span class="text-base-content/70">{profile.company.town}, {profile.company.state}</span>
</div>
{/if}
{#if profile.company.phone}
<div class="flex items-center gap-2">
<Phone size={14} class="text-base-content/40 flex-shrink-0" />
<a href="tel:{profile.company.phone}" class="text-primary hover:underline font-medium">
{formatPhone(profile.company.phone)}
</a>
</div>
{/if}
{#if profile.company.email}
<div class="flex items-center gap-2">
<Mail size={14} class="text-base-content/40 flex-shrink-0" />
<a href="mailto:{profile.company.email}" class="text-primary hover:underline">
{profile.company.email}
</a>
</div>
{/if}
{#if profile.company.owner_name}
<div class="flex items-center gap-2">
<User size={14} class="text-base-content/40 flex-shrink-0" />
<span class="text-base-content/70">Owner: {profile.company.owner_name}</span>
</div>
{/if}
</div>
</div>
</div>
<!-- Stats Card -->
<div class="card bg-base-100 shadow-md border border-base-300/50">
<div class="card-body p-5">
<h2 class="text-lg font-bold text-base-content flex items-center gap-2 mb-3">
<Shield size={18} class="text-primary" />
Quick Stats
</h2>
<div class="grid grid-cols-2 gap-3">
<div class="text-center p-3 rounded-xl bg-primary/5">
<div class="text-2xl font-bold text-primary">{profile.fuel_listings.length}</div>
<div class="text-xs text-base-content/50">Fuel Listings</div>
</div>
<div class="text-center p-3 rounded-xl bg-success/5">
<div class="text-2xl font-bold text-success">{profile.service_listings.length}</div>
<div class="text-xs text-base-content/50">Services</div>
</div>
</div>
</div>
</div>
</div>
<!-- Right content — Listings -->
<div class="lg:col-span-2 space-y-6">
<!-- Fuel Listings -->
{#if profile.fuel_listings.length > 0}
<div class="card bg-base-100 shadow-md border border-base-300/50">
<div class="card-body p-5">
<h2 class="text-lg font-bold text-base-content flex items-center gap-2 mb-4">
<Flame size={18} class="text-oil-orange-500" />
Fuel Listings
<span class="badge badge-sm badge-primary">{profile.fuel_listings.length}</span>
</h2>
<div class="space-y-3">
{#each profile.fuel_listings as listing}
<div class="flex items-center justify-between p-4 rounded-xl bg-base-200/50 border border-base-300/30 hover:border-primary/20 transition-colors">
<div class="flex items-center gap-3 min-w-0 flex-1">
<div class="w-10 h-10 rounded-full overflow-hidden bg-primary flex items-center justify-center flex-shrink-0">
{#if listing.logo_url}
<img src="{PUBLIC_API_URL}{listing.logo_url}" alt="" class="w-full h-full object-cover" />
{:else}
<span class="text-sm text-primary-content font-bold">{listing.company_name.charAt(0).toUpperCase()}</span>
{/if}
</div>
<div class="min-w-0">
<div class="font-semibold text-base-content truncate">{listing.company_name}</div>
<div class="text-xs text-base-content/50 flex items-center gap-2 flex-wrap">
{#if listing.town}
<span class="flex items-center gap-0.5"><MapPin size={10} /> {listing.town}</span>
{/if}
<span class="flex items-center gap-0.5"><Droplets size={10} /> Bio {listing.bio_percent}%</span>
<span class="flex items-center gap-0.5"><Clock size={10} /> {formatDate(listing.last_edited)}</span>
</div>
</div>
</div>
<div class="text-right flex-shrink-0 ml-3">
<div class="text-xl font-bold text-primary">${listing.price_per_gallon.toFixed(2)}</div>
<div class="text-xs text-base-content/40">per gallon</div>
{#if listing.price_per_gallon_cash}
<div class="text-xs text-success font-medium mt-0.5">Cash: ${listing.price_per_gallon_cash.toFixed(2)}</div>
{/if}
</div>
</div>
{/each}
</div>
</div>
</div>
{/if}
<!-- Service Listings -->
{#if profile.service_listings.length > 0}
<div class="card bg-base-100 shadow-md border border-base-300/50">
<div class="card-body p-5">
<h2 class="text-lg font-bold text-base-content flex items-center gap-2 mb-4">
<Wrench size={18} class="text-success" />
Service Offerings
<span class="badge badge-sm badge-success">{profile.service_listings.length}</span>
</h2>
<div class="space-y-3">
{#each profile.service_listings as svc}
<div class="p-4 rounded-xl bg-base-200/50 border border-base-300/30 hover:border-success/20 transition-colors">
<div class="flex items-start justify-between gap-3">
<div class="flex gap-3 min-w-0 flex-1">
<div class="w-10 h-10 rounded-full overflow-hidden bg-success flex items-center justify-center flex-shrink-0 mt-0.5">
{#if svc.logo_url}
<img src="{PUBLIC_API_URL}{svc.logo_url}" alt="" class="w-full h-full object-cover" />
{:else}
<span class="text-sm text-success-content font-bold">{svc.company_name.charAt(0).toUpperCase()}</span>
{/if}
</div>
<div class="min-w-0">
<div class="font-semibold text-base-content">{svc.company_name}</div>
{#if svc.description}
<p class="text-sm text-base-content/60 mt-1">{svc.description}</p>
{/if}
<div class="flex items-center gap-2 flex-wrap mt-2">
{#if svc.twenty_four_hour}
<span class="badge badge-success badge-sm gap-1">24hr</span>
{/if}
{#if svc.emergency_service}
<span class="badge badge-warning badge-sm gap-1">Emergency</span>
{/if}
{#if svc.licensed_insured}
<span class="badge badge-info badge-sm gap-1">Licensed & Insured</span>
{/if}
{#if svc.years_experience}
<span class="badge badge-neutral badge-sm">{svc.years_experience}+ yrs</span>
{/if}
</div>
</div>
</div>
{#if svc.phone}
<a
href="tel:{svc.phone}"
class="btn btn-sm btn-success btn-outline gap-1 flex-shrink-0"
>
<Phone size={14} />
Call
</a>
{/if}
</div>
</div>
{/each}
</div>
</div>
</div>
{/if}
<!-- Empty state -->
{#if profile.fuel_listings.length === 0 && profile.service_listings.length === 0}
<div class="card bg-base-100 shadow-md border border-base-300/50">
<div class="card-body text-center py-12">
<Building2 size={40} class="mx-auto text-base-content/20 mb-3" />
<p class="text-lg font-semibold text-base-content">No active listings</p>
<p class="text-sm text-base-content/50">This company hasn't published any listings yet.</p>
</div>
</div>
{/if}
</div>
</div>
</div>
{/if}
{/if}

View File

@@ -0,0 +1,362 @@
<!-- src/routes/(app)/company/user/[userId]/+page.svelte -->
<script lang="ts">
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { PUBLIC_API_URL } from '$env/static/public';
import type { CompanyProfile } from '$lib/api';
import { fade, fly } from 'svelte/transition';
import {
ChevronLeft,
Phone,
Mail,
MapPin,
Globe,
DollarSign,
Wrench,
Droplets,
Clock,
Building2,
User,
Calendar,
Shield,
Flame,
AlertCircle,
} from 'lucide-svelte';
const { userId } = $page.params as { userId: string };
let profile: CompanyProfile | null = null;
let loading = true;
let error: string | null = null;
// Section reveals
let headerVisible = false;
let contentVisible = false;
onMount(async () => {
const result = await api.companyProfile.getByUserId(parseInt(userId));
if (result.error) {
error = result.error;
} else {
profile = result.data;
}
loading = false;
headerVisible = true;
setTimeout(() => { contentVisible = true; }, 250);
});
function formatDate(dateStr: string | undefined | null): string {
if (!dateStr) return 'N/A';
try {
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
} catch {
return dateStr;
}
}
function formatPhone(phone: string): string {
const cleaned = phone.replace(/\D/g, '');
if (cleaned.length === 10) {
return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6)}`;
} else if (cleaned.length === 11 && cleaned.startsWith('1')) {
return `+1 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7)}`;
}
return phone;
}
</script>
<!-- SEO -->
<svelte:head>
{#if profile}
<title>{profile.company.name} | LocalOilPrices</title>
<meta name="description" content="View company profile for {profile.company.name}. Find heating oil prices, service offerings, and contact information." />
{:else}
<title>Company Profile | LocalOilPrices</title>
{/if}
</svelte:head>
{#if loading}
<div class="py-10 px-2">
<div class="skeleton-box h-4 w-48 mb-6"></div>
<div class="skeleton-box h-40 w-full max-w-3xl mx-auto mb-6 rounded-xl"></div>
<div class="skeleton-box h-8 w-64 mx-auto mb-4"></div>
<div class="skeleton-box h-5 w-80 mx-auto"></div>
</div>
{:else if error || !profile}
<div class="text-center py-16">
<AlertCircle size={48} class="mx-auto text-error/50 mb-4" />
<h1 class="text-3xl font-bold text-error mb-3">Company Not Found</h1>
<p class="text-base-content/60 text-lg mb-6">
{error || 'Could not find this company.'}
</p>
<a href="/" class="btn btn-primary gap-1">
<ChevronLeft size={16} />
Back to Home
</a>
</div>
{:else}
<!-- Breadcrumb -->
<nav class="mb-6 px-2" aria-label="Breadcrumb">
<ol class="flex items-center gap-1 text-sm">
<li>
<a href="/" class="inline-flex items-center gap-1 text-primary hover:underline focus:outline-none focus:ring-2 focus:ring-primary rounded">
<ChevronLeft size={14} />
<span>Home</span>
</a>
</li>
<li class="text-base-content/40" aria-hidden="true">/</li>
<li class="font-medium text-base-content" aria-current="page">{profile.company.name}</li>
</ol>
</nav>
<!-- Company Profile Header -->
{#if headerVisible}
<section class="px-2 mb-8" in:fly={{ y: 24, duration: 500 }}>
<!-- Banner / Cover area -->
<div class="relative rounded-2xl overflow-hidden bg-gradient-to-r from-primary/20 via-primary/10 to-accent/20 h-32 sm:h-44 md:h-52 mb-4">
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-base-100/50"></div>
</div>
<!-- Avatar & Name overlay -->
<div class="flex flex-col sm:flex-row items-center sm:items-end gap-4 -mt-14 sm:-mt-16 px-4 sm:px-8">
<!-- Avatar -->
<div class="w-24 h-24 sm:w-28 sm:h-28 rounded-full bg-primary flex items-center justify-center border-4 border-base-100 shadow-lg flex-shrink-0 z-10">
<span class="text-4xl sm:text-5xl text-primary-content font-bold">
{profile.company.name.charAt(0).toUpperCase()}
</span>
</div>
<!-- Name & meta -->
<div class="text-center sm:text-left flex-1 pb-2">
<h1 class="text-2xl sm:text-3xl md:text-4xl font-bold text-base-content leading-tight">
{profile.company.name}
</h1>
{#if profile.company.town && profile.company.state}
<p class="text-base text-base-content/60 flex items-center gap-1 mt-1 justify-center sm:justify-start">
<MapPin size={14} />
{profile.company.town}, {profile.company.state}
</p>
{/if}
<p class="text-sm text-base-content/40 flex items-center gap-1 mt-0.5 justify-center sm:justify-start">
<Calendar size={13} />
Member since {formatDate(profile.company.created)}
</p>
</div>
<!-- Contact button -->
{#if profile.company.phone}
<a
href="tel:{profile.company.phone}"
class="btn btn-primary gap-2 flex-shrink-0"
>
<Phone size={16} />
Call {formatPhone(profile.company.phone)}
</a>
{/if}
</div>
</section>
{/if}
<!-- Content -->
{#if contentVisible}
<div class="px-2 pb-10" in:fade={{ duration: 400 }}>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 max-w-6xl mx-auto">
<!-- Left sidebar — Company Info -->
<div class="lg:col-span-1 space-y-4">
<!-- About Card -->
<div class="card bg-base-100 shadow-md border border-base-300/50">
<div class="card-body p-5">
<h2 class="text-lg font-bold text-base-content flex items-center gap-2 mb-3">
<Building2 size={18} class="text-primary" />
About
</h2>
<div class="space-y-3 text-sm">
{#if profile.company.address}
<div class="flex items-start gap-2">
<MapPin size={14} class="text-base-content/40 mt-0.5 flex-shrink-0" />
<span class="text-base-content/70">{profile.company.address}</span>
</div>
{/if}
{#if profile.company.town && profile.company.state}
<div class="flex items-start gap-2">
<MapPin size={14} class="text-base-content/40 mt-0.5 flex-shrink-0" />
<span class="text-base-content/70">{profile.company.town}, {profile.company.state}</span>
</div>
{/if}
{#if profile.company.phone}
<div class="flex items-center gap-2">
<Phone size={14} class="text-base-content/40 flex-shrink-0" />
<a href="tel:{profile.company.phone}" class="text-primary hover:underline font-medium">
{formatPhone(profile.company.phone)}
</a>
</div>
{/if}
{#if profile.company.email}
<div class="flex items-center gap-2">
<Mail size={14} class="text-base-content/40 flex-shrink-0" />
<a href="mailto:{profile.company.email}" class="text-primary hover:underline">
{profile.company.email}
</a>
</div>
{/if}
{#if profile.company.owner_name}
<div class="flex items-center gap-2">
<User size={14} class="text-base-content/40 flex-shrink-0" />
<span class="text-base-content/70">Owner: {profile.company.owner_name}</span>
</div>
{/if}
</div>
</div>
</div>
<!-- Stats Card -->
<div class="card bg-base-100 shadow-md border border-base-300/50">
<div class="card-body p-5">
<h2 class="text-lg font-bold text-base-content flex items-center gap-2 mb-3">
<Shield size={18} class="text-primary" />
Quick Stats
</h2>
<div class="grid grid-cols-2 gap-3">
<div class="text-center p-3 rounded-xl bg-primary/5">
<div class="text-2xl font-bold text-primary">{profile.fuel_listings.length}</div>
<div class="text-xs text-base-content/50">Fuel Listings</div>
</div>
<div class="text-center p-3 rounded-xl bg-success/5">
<div class="text-2xl font-bold text-success">{profile.service_listings.length}</div>
<div class="text-xs text-base-content/50">Services</div>
</div>
</div>
</div>
</div>
</div>
<!-- Right content — Listings -->
<div class="lg:col-span-2 space-y-6">
<!-- Fuel Listings -->
{#if profile.fuel_listings.length > 0}
<div class="card bg-base-100 shadow-md border border-base-300/50">
<div class="card-body p-5">
<h2 class="text-lg font-bold text-base-content flex items-center gap-2 mb-4">
<Flame size={18} class="text-oil-orange-500" />
Fuel Listings
<span class="badge badge-sm badge-primary">{profile.fuel_listings.length}</span>
</h2>
<div class="space-y-3">
{#each profile.fuel_listings as listing}
<div class="flex items-center justify-between p-4 rounded-xl bg-base-200/50 border border-base-300/30 hover:border-primary/20 transition-colors">
<div class="flex items-center gap-3 min-w-0 flex-1">
<div class="w-10 h-10 rounded-full overflow-hidden bg-primary flex items-center justify-center flex-shrink-0">
{#if listing.logo_url}
<img src="{PUBLIC_API_URL}{listing.logo_url}" alt="" class="w-full h-full object-cover" />
{:else}
<span class="text-sm text-primary-content font-bold">{listing.company_name.charAt(0).toUpperCase()}</span>
{/if}
</div>
<div class="min-w-0">
<div class="font-semibold text-base-content truncate">{listing.company_name}</div>
<div class="text-xs text-base-content/50 flex items-center gap-2 flex-wrap">
{#if listing.town}
<span class="flex items-center gap-0.5"><MapPin size={10} /> {listing.town}</span>
{/if}
<span class="flex items-center gap-0.5"><Droplets size={10} /> Bio {listing.bio_percent}%</span>
<span class="flex items-center gap-0.5"><Clock size={10} /> {formatDate(listing.last_edited)}</span>
</div>
</div>
</div>
<div class="text-right flex-shrink-0 ml-3">
<div class="text-xl font-bold text-primary">${listing.price_per_gallon.toFixed(2)}</div>
<div class="text-xs text-base-content/40">per gallon</div>
{#if listing.price_per_gallon_cash}
<div class="text-xs text-success font-medium mt-0.5">Cash: ${listing.price_per_gallon_cash.toFixed(2)}</div>
{/if}
</div>
</div>
{/each}
</div>
</div>
</div>
{/if}
<!-- Service Listings -->
{#if profile.service_listings.length > 0}
<div class="card bg-base-100 shadow-md border border-base-300/50">
<div class="card-body p-5">
<h2 class="text-lg font-bold text-base-content flex items-center gap-2 mb-4">
<Wrench size={18} class="text-success" />
Service Offerings
<span class="badge badge-sm badge-success">{profile.service_listings.length}</span>
</h2>
<div class="space-y-3">
{#each profile.service_listings as svc}
<div class="p-4 rounded-xl bg-base-200/50 border border-base-300/30 hover:border-success/20 transition-colors">
<div class="flex items-start justify-between gap-3">
<div class="flex gap-3 min-w-0 flex-1">
<div class="w-10 h-10 rounded-full overflow-hidden bg-success flex items-center justify-center flex-shrink-0 mt-0.5">
{#if svc.logo_url}
<img src="{PUBLIC_API_URL}{svc.logo_url}" alt="" class="w-full h-full object-cover" />
{:else}
<span class="text-sm text-success-content font-bold">{svc.company_name.charAt(0).toUpperCase()}</span>
{/if}
</div>
<div class="min-w-0">
<div class="font-semibold text-base-content">{svc.company_name}</div>
{#if svc.description}
<p class="text-sm text-base-content/60 mt-1">{svc.description}</p>
{/if}
<div class="flex items-center gap-2 flex-wrap mt-2">
{#if svc.twenty_four_hour}
<span class="badge badge-success badge-sm gap-1">24hr</span>
{/if}
{#if svc.emergency_service}
<span class="badge badge-warning badge-sm gap-1">Emergency</span>
{/if}
{#if svc.licensed_insured}
<span class="badge badge-info badge-sm gap-1">Licensed & Insured</span>
{/if}
{#if svc.years_experience}
<span class="badge badge-neutral badge-sm">{svc.years_experience}+ yrs</span>
{/if}
</div>
</div>
</div>
{#if svc.phone}
<a
href="tel:{svc.phone}"
class="btn btn-sm btn-success btn-outline gap-1 flex-shrink-0"
>
<Phone size={14} />
Call
</a>
{/if}
</div>
</div>
{/each}
</div>
</div>
</div>
{/if}
<!-- Empty state -->
{#if profile.fuel_listings.length === 0 && profile.service_listings.length === 0}
<div class="card bg-base-100 shadow-md border border-base-300/50">
<div class="card-body text-center py-12">
<Building2 size={40} class="mx-auto text-base-content/20 mb-3" />
<p class="text-lg font-semibold text-base-content">No active listings</p>
<p class="text-sm text-base-content/50">This company hasn't published any listings yet.</p>
</div>
</div>
{/if}
</div>
</div>
</div>
{/if}
{/if}

View File

@@ -0,0 +1,347 @@
<!-- src/routes/(app)/listing/[id]/+page.svelte — Fuel Listing Profile Page -->
<script lang="ts">
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { PUBLIC_API_URL } from '$env/static/public';
import type { Listing, County } from '$lib/api';
import { fade, fly } from 'svelte/transition';
import {
ChevronLeft,
Phone,
MapPin,
Globe,
Droplets,
Clock,
Building2,
Calendar,
Flame,
AlertCircle,
DollarSign,
ShoppingCart,
FileText,
ExternalLink,
} from 'lucide-svelte';
const { id } = $page.params as { id: string };
let listing: Listing | null = null;
let county: County | null = null;
let loading = true;
let error: string | null = null;
let headerVisible = false;
let contentVisible = false;
onMount(async () => {
const result = await api.listings.getById(parseInt(id));
if (result.error) {
error = result.error;
} else {
listing = result.data;
// Fetch county name for breadcrumb
if (listing) {
const countyResult = await api.state.getCounty('', listing.county_id);
if (!countyResult.error && countyResult.data) {
county = countyResult.data;
}
}
}
loading = false;
headerVisible = true;
setTimeout(() => { contentVisible = true; }, 250);
});
function formatDate(dateStr: string | undefined | null): string {
if (!dateStr) return 'N/A';
try {
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
} catch { return dateStr; }
}
function formatPhone(phone: string): string {
const cleaned = phone.replace(/\D/g, '');
if (cleaned.length === 10) return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6)}`;
if (cleaned.length === 11 && cleaned.startsWith('1')) return `+1 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7)}`;
return phone;
}
</script>
<svelte:head>
{#if listing}
<link rel="alternate" type="application/json" href="{PUBLIC_API_URL}/listings/{listing.id}" />
<title>{listing.company_name} — Heating Oil | LocalOilPrices</title>
<meta name="description" content="{listing.company_name} offers heating oil at ${listing.price_per_gallon.toFixed(2)}/gallon{listing.town ? ` in ${listing.town}` : ''}. View details and contact info." />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "LocalBusiness",
"name": "{listing.company_name}",
"telephone": "{listing.phone || ''}",
"url": "{listing.url ? (listing.url.startsWith('http') ? listing.url : 'https://' + listing.url) : 'https://localoilprices.com/listing/' + id}",
"address": {
"@type": "PostalAddress",
"addressLocality": "{listing.town || ''}",
"addressRegion": "{county ? county.state : ''}"
},
"makesOffer": {
"@type": "Offer",
"priceCurrency": "USD",
"price": "{listing.price_per_gallon}",
"itemOffered": {
"@type": "Product",
"name": "Heating Oil"
}
}
}
</script>
{:else}
<title>Listing Profile | LocalOilPrices</title>
{/if}
</svelte:head>
{#if loading}
<div class="py-10 px-2">
<div class="skeleton-box h-4 w-48 mb-6"></div>
<div class="skeleton-box h-40 w-full max-w-3xl mx-auto mb-6 rounded-xl"></div>
<div class="skeleton-box h-8 w-64 mx-auto mb-4"></div>
</div>
{:else if error || !listing}
<div class="text-center py-16">
<AlertCircle size={48} class="mx-auto text-error/50 mb-4" />
<h1 class="text-3xl font-bold text-error mb-3">Listing Not Found</h1>
<p class="text-base-content/60 text-lg mb-6">{error || 'Could not find this listing.'}</p>
<a href="/" class="btn btn-primary gap-1"><ChevronLeft size={16} />Back to Home</a>
</div>
{:else}
<!-- Breadcrumb -->
<nav class="mb-6 px-2" aria-label="Breadcrumb">
<ol class="flex items-center gap-1 text-sm flex-wrap">
<li>
<a href="/" class="inline-flex items-center gap-1 text-primary hover:underline">
<ChevronLeft size={14} /><span>Home</span>
</a>
</li>
{#if county}
<li class="text-base-content/40" aria-hidden="true">/</li>
<li>
<a href="/{county.state.toLowerCase()}/{county.name.toLowerCase().replace(/\s+/g, '-')}" class="text-primary hover:underline">
{county.name}, {county.state}
</a>
</li>
{/if}
<li class="text-base-content/40" aria-hidden="true">/</li>
<li class="font-medium text-base-content" aria-current="page">{listing.company_name}</li>
</ol>
</nav>
<!-- Header -->
{#if headerVisible}
<section class="px-2 mb-8" in:fly={{ y: 24, duration: 500 }}>
<!-- Banner -->
<div class="relative rounded-2xl overflow-hidden h-24 sm:h-32 md:h-36 mb-4">
{#if listing.banner_url}
<img src="{PUBLIC_API_URL}{listing.banner_url}" alt="{listing.company_name} banner" class="w-full h-full object-cover" />
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-base-100/60"></div>
{:else}
<div class="w-full h-full bg-gradient-to-r from-primary/20 via-primary/10 to-accent/20"></div>
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-base-100/50"></div>
{/if}
</div>
<!-- Avatar & Name -->
<div class="flex flex-col sm:flex-row items-center sm:items-end gap-4 mt-2 px-4 sm:px-8">
<div class="w-24 h-24 sm:w-28 sm:h-28 rounded-full overflow-hidden bg-primary flex items-center justify-center border-4 border-base-100 shadow-lg flex-shrink-0 z-10">
{#if listing.logo_url}
<img src="{PUBLIC_API_URL}{listing.logo_url}" alt="" class="w-full h-full object-cover" />
{:else}
<span class="text-4xl sm:text-5xl text-primary-content font-bold">{listing.company_name.charAt(0).toUpperCase()}</span>
{/if}
</div>
<div class="text-center sm:text-left flex-1 pb-2">
<h1 class="text-2xl sm:text-3xl md:text-4xl font-bold text-base-content leading-tight">{listing.company_name}</h1>
{#if listing.town}
<p class="text-base text-base-content/60 flex items-center gap-1 mt-1 justify-center sm:justify-start">
<MapPin size={14} />{listing.town}{county ? `, ${county.state}` : ''}
</p>
{/if}
<p class="text-sm text-base-content/40 flex items-center gap-1 mt-0.5 justify-center sm:justify-start">
<Clock size={13} />Last updated {formatDate(listing.last_edited)}
</p>
</div>
<!-- Price CTA -->
<div class="flex flex-col items-center gap-1 flex-shrink-0 pb-2">
<div class="text-3xl sm:text-4xl font-bold text-primary">${listing.price_per_gallon.toFixed(2)}</div>
<div class="text-xs text-base-content/50">per gallon</div>
{#if listing.price_per_gallon_cash}
<div class="text-sm text-success font-semibold">Cash: ${listing.price_per_gallon_cash.toFixed(2)}</div>
{/if}
</div>
</div>
</section>
{/if}
<!-- Content -->
{#if contentVisible}
<div class="px-2 pb-10" in:fade={{ duration: 400 }}>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 max-w-6xl mx-auto">
<!-- Left sidebar -->
<div class="lg:col-span-1 space-y-4">
<!-- Contact Card -->
<div class="card bg-base-100 shadow-md border border-base-300/50">
<div class="card-body p-5">
<h2 class="text-lg font-bold text-base-content flex items-center gap-2 mb-3">
<Building2 size={18} class="text-primary" />Contact
</h2>
<div class="space-y-3 text-sm">
{#if listing.phone}
<div class="flex items-center gap-2">
<Phone size={14} class="text-base-content/40 flex-shrink-0" />
<a href="tel:{listing.phone}" class="text-primary hover:underline font-medium">{formatPhone(listing.phone)}</a>
</div>
{/if}
{#if listing.url}
<div class="flex items-center gap-2">
<Globe size={14} class="text-base-content/40 flex-shrink-0" />
<a href="{listing.url.startsWith('http') ? listing.url : 'https://' + listing.url}" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline truncate">{listing.url}</a>
</div>
{/if}
{#if listing.town}
<div class="flex items-center gap-2">
<MapPin size={14} class="text-base-content/40 flex-shrink-0" />
<span class="text-base-content/70">{listing.town}{county ? `, ${county.state}` : ''}</span>
</div>
{/if}
</div>
{#if listing.phone}
<a href="tel:{listing.phone}" class="btn btn-primary gap-2 mt-4 w-full">
<Phone size={16} />Call Now
</a>
{/if}
</div>
</div>
<!-- Social Links -->
{#if listing.facebook_url || listing.instagram_url || listing.google_business_url}
<div class="card bg-base-100 shadow-md border border-base-300/50">
<div class="card-body p-5">
<h2 class="text-lg font-bold text-base-content mb-3">Follow</h2>
<div class="flex flex-col gap-2">
{#if listing.facebook_url}
<a href="{listing.facebook_url.startsWith('http') ? listing.facebook_url : 'https://' + listing.facebook_url}" target="_blank" rel="noopener noreferrer" class="btn btn-outline btn-sm gap-2 justify-start">
<ExternalLink size={14} />Facebook
</a>
{/if}
{#if listing.instagram_url}
<a href="{listing.instagram_url.startsWith('http') ? listing.instagram_url : 'https://' + listing.instagram_url}" target="_blank" rel="noopener noreferrer" class="btn btn-outline btn-sm gap-2 justify-start">
<ExternalLink size={14} />Instagram
</a>
{/if}
{#if listing.google_business_url}
<a href="{listing.google_business_url.startsWith('http') ? listing.google_business_url : 'https://' + listing.google_business_url}" target="_blank" rel="noopener noreferrer" class="btn btn-outline btn-sm gap-2 justify-start">
<ExternalLink size={14} />Google Business
</a>
{/if}
</div>
</div>
</div>
{/if}
</div>
<!-- Right content — Details -->
<div class="lg:col-span-2 space-y-6">
<!-- Pricing Details -->
<div class="card bg-base-100 shadow-md border border-base-300/50">
<div class="card-body p-5">
<h2 class="text-lg font-bold text-base-content flex items-center gap-2 mb-4">
<DollarSign size={18} class="text-primary" />Pricing Details
</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4">
<div class="text-center p-4 rounded-xl bg-primary/5 border border-primary/10">
<div class="text-2xl font-bold text-primary">${listing.price_per_gallon.toFixed(2)}</div>
<div class="text-xs text-base-content/50 mt-1">Credit/Check Price</div>
</div>
{#if listing.price_per_gallon_cash}
<div class="text-center p-4 rounded-xl bg-success/5 border border-success/10">
<div class="text-2xl font-bold text-success">${listing.price_per_gallon_cash.toFixed(2)}</div>
<div class="text-xs text-base-content/50 mt-1">Cash Price</div>
</div>
{/if}
<div class="text-center p-4 rounded-xl bg-info/5 border border-info/10">
<div class="text-2xl font-bold text-info">{listing.bio_percent}%</div>
<div class="text-xs text-base-content/50 mt-1">Bio Blend</div>
</div>
</div>
</div>
</div>
<!-- Service Details -->
<div class="card bg-base-100 shadow-md border border-base-300/50">
<div class="card-body p-5">
<h2 class="text-lg font-bold text-base-content flex items-center gap-2 mb-4">
<Flame size={18} class="text-oil-orange-500" />Service Details
</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
<div class="flex items-center gap-2 p-3 rounded-lg bg-base-200/50">
<ShoppingCart size={16} class="text-base-content/40" />
<div>
<div class="font-medium text-base-content">Minimum Order</div>
<div class="text-base-content/60">{listing.minimum_order ? `${listing.minimum_order} gallons` : 'No minimum'}</div>
</div>
</div>
<div class="flex items-center gap-2 p-3 rounded-lg bg-base-200/50">
<Globe size={16} class="text-base-content/40" />
<div>
<div class="font-medium text-base-content">Online Ordering</div>
<div class="text-base-content/60 capitalize">{listing.online_ordering || 'None'}</div>
</div>
</div>
<div class="flex items-center gap-2 p-3 rounded-lg bg-base-200/50">
<Droplets size={16} class="text-base-content/40" />
<div>
<div class="font-medium text-base-content">Bio Blend</div>
<div class="text-base-content/60">{listing.bio_percent}% biodiesel blend</div>
</div>
</div>
{#if listing.service}
<div class="flex items-center gap-2 p-3 rounded-lg bg-success/5 border border-success/10">
<Building2 size={16} class="text-success" />
<div>
<div class="font-medium text-success">Full Service</div>
<div class="text-base-content/60">Service contracts available</div>
</div>
</div>
{/if}
</div>
{#if listing.note}
<div class="mt-4 p-3 rounded-lg bg-base-200/50 border border-base-300/30">
<div class="flex items-center gap-2 mb-1">
<FileText size={14} class="text-base-content/40" />
<span class="font-medium text-sm text-base-content">Note</span>
</div>
<p class="text-sm text-base-content/70">{listing.note}</p>
</div>
{/if}
</div>
</div>
<!-- Additional Towns Serviced -->
{#if listing.towns_serviced && listing.towns_serviced.length > 0}
<div class="card bg-base-100 shadow-md border border-base-300/50">
<div class="card-body p-5">
<h2 class="text-lg font-bold text-base-content flex items-center gap-2 mb-4">
<MapPin size={18} class="text-primary" />Also Servicing
</h2>
<div class="flex flex-wrap gap-2">
{#each listing.towns_serviced as town}
<span class="badge badge-outline badge-lg">{town}</span>
{/each}
</div>
</div>
</div>
{/if}
</div>
</div>
</div>
{/if}
{/if}

View File

@@ -0,0 +1,324 @@
<!-- src/routes/(app)/service/[id]/+page.svelte — Service Listing Profile Page -->
<script lang="ts">
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { PUBLIC_API_URL } from '$env/static/public';
import type { ServiceListing, County } from '$lib/api';
import { fade, fly } from 'svelte/transition';
import {
ChevronLeft,
Phone,
MapPin,
Globe,
Clock,
Building2,
AlertCircle,
Wrench,
Shield,
Mail,
ExternalLink,
} from 'lucide-svelte';
const { id } = $page.params as { id: string };
let listing: ServiceListing | null = null;
let county: County | null = null;
let loading = true;
let error: string | null = null;
let headerVisible = false;
let contentVisible = false;
onMount(async () => {
const result = await api.serviceListings.getById(parseInt(id));
if (result.error) {
error = result.error;
} else {
listing = result.data;
if (listing) {
const countyResult = await api.state.getCounty('', listing.county_id);
if (!countyResult.error && countyResult.data) {
county = countyResult.data;
}
}
}
loading = false;
headerVisible = true;
setTimeout(() => { contentVisible = true; }, 250);
});
function formatDate(dateStr: string | undefined | null): string {
if (!dateStr) return 'N/A';
try { return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); }
catch { return dateStr; }
}
function formatPhone(phone: string): string {
const cleaned = phone.replace(/\D/g, '');
if (cleaned.length === 10) return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6)}`;
if (cleaned.length === 11 && cleaned.startsWith('1')) return `+1 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7)}`;
return phone;
}
</script>
<svelte:head>
{#if listing}
<link rel="alternate" type="application/json" href="{PUBLIC_API_URL}/service-listings/{listing.id}" />
<title>{listing.company_name} — Service Company | LocalOilPrices</title>
<meta name="description" content="{listing.company_name} provides HVAC and heating services{listing.town ? ` in ${listing.town}` : ''}. {listing.emergency_service ? 'Emergency service available.' : ''}" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "HomeAndConstructionBusiness",
"name": "{listing.company_name}",
"telephone": "{listing.phone || ''}",
"email": "{listing.email || ''}",
"url": "{listing.website ? (listing.website.startsWith('http') ? listing.website : 'https://' + listing.website) : 'https://localoilprices.com/service/' + id}",
"address": {
"@type": "PostalAddress",
"addressLocality": "{listing.town || ''}",
"addressRegion": "{county ? county.state : ''}"
},
"description": "{listing.description || ''}"
}
</script>
{:else}
<title>Service Listing | LocalOilPrices</title>
{/if}
</svelte:head>
{#if loading}
<div class="py-10 px-2">
<div class="skeleton-box h-4 w-48 mb-6"></div>
<div class="skeleton-box h-40 w-full max-w-3xl mx-auto mb-6 rounded-xl"></div>
<div class="skeleton-box h-8 w-64 mx-auto mb-4"></div>
</div>
{:else if error || !listing}
<div class="text-center py-16">
<AlertCircle size={48} class="mx-auto text-error/50 mb-4" />
<h1 class="text-3xl font-bold text-error mb-3">Service Listing Not Found</h1>
<p class="text-base-content/60 text-lg mb-6">{error || 'Could not find this service listing.'}</p>
<a href="/" class="btn btn-primary gap-1"><ChevronLeft size={16} />Back to Home</a>
</div>
{:else}
<!-- Breadcrumb -->
<nav class="mb-6 px-2" aria-label="Breadcrumb">
<ol class="flex items-center gap-1 text-sm flex-wrap">
<li>
<a href="/" class="inline-flex items-center gap-1 text-primary hover:underline">
<ChevronLeft size={14} /><span>Home</span>
</a>
</li>
{#if county}
<li class="text-base-content/40" aria-hidden="true">/</li>
<li>
<a href="/{county.state.toLowerCase()}/{county.name.toLowerCase().replace(/\s+/g, '-')}" class="text-primary hover:underline">
{county.name}, {county.state}
</a>
</li>
{/if}
<li class="text-base-content/40" aria-hidden="true">/</li>
<li class="font-medium text-base-content" aria-current="page">{listing.company_name}</li>
</ol>
</nav>
<!-- Header -->
{#if headerVisible}
<section class="px-2 mb-8" in:fly={{ y: 24, duration: 500 }}>
<div class="relative rounded-2xl overflow-hidden h-24 sm:h-32 md:h-36 mb-4">
{#if listing.banner_url}
<img src="{PUBLIC_API_URL}{listing.banner_url}" alt="{listing.company_name} banner" class="w-full h-full object-cover" />
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-base-100/60"></div>
{:else}
<div class="w-full h-full bg-gradient-to-r from-success/20 via-success/10 to-accent/20"></div>
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-base-100/50"></div>
{/if}
</div>
<div class="flex flex-col sm:flex-row items-center sm:items-end gap-4 mt-2 px-4 sm:px-8">
<div class="w-24 h-24 sm:w-28 sm:h-28 rounded-full overflow-hidden bg-success flex items-center justify-center border-4 border-base-100 shadow-lg flex-shrink-0 z-10">
{#if listing.logo_url}
<img src="{PUBLIC_API_URL}{listing.logo_url}" alt="" class="w-full h-full object-cover" />
{:else}
<span class="text-4xl sm:text-5xl text-success-content font-bold">{listing.company_name.charAt(0).toUpperCase()}</span>
{/if}
</div>
<div class="text-center sm:text-left flex-1 pb-2">
<h1 class="text-2xl sm:text-3xl md:text-4xl font-bold text-base-content leading-tight">{listing.company_name}</h1>
{#if listing.town}
<p class="text-base text-base-content/60 flex items-center gap-1 mt-1 justify-center sm:justify-start">
<MapPin size={14} />{listing.town}{county ? `, ${county.state}` : ''}
</p>
{/if}
<div class="flex items-center gap-2 flex-wrap mt-2 justify-center sm:justify-start">
{#if listing.twenty_four_hour}
<span class="badge badge-success gap-1">24/7 Service</span>
{/if}
{#if listing.emergency_service}
<span class="badge badge-warning gap-1">Emergency Service</span>
{/if}
{#if listing.licensed_insured}
<span class="badge badge-info gap-1">Licensed & Insured</span>
{/if}
</div>
</div>
{#if listing.phone}
<a href="tel:{listing.phone}" class="btn btn-success gap-2 flex-shrink-0">
<Phone size={16} />Call {formatPhone(listing.phone)}
</a>
{/if}
</div>
</section>
{/if}
<!-- Content -->
{#if contentVisible}
<div class="px-2 pb-10" in:fade={{ duration: 400 }}>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 max-w-6xl mx-auto">
<!-- Left sidebar -->
<div class="lg:col-span-1 space-y-4">
<!-- Contact Card -->
<div class="card bg-base-100 shadow-md border border-base-300/50">
<div class="card-body p-5">
<h2 class="text-lg font-bold text-base-content flex items-center gap-2 mb-3">
<Building2 size={18} class="text-success" />Contact
</h2>
<div class="space-y-3 text-sm">
{#if listing.phone}
<div class="flex items-center gap-2">
<Phone size={14} class="text-base-content/40 flex-shrink-0" />
<a href="tel:{listing.phone}" class="text-primary hover:underline font-medium">{formatPhone(listing.phone)}</a>
</div>
{/if}
{#if listing.email}
<div class="flex items-center gap-2">
<Mail size={14} class="text-base-content/40 flex-shrink-0" />
<a href="mailto:{listing.email}" class="text-primary hover:underline">{listing.email}</a>
</div>
{/if}
{#if listing.website}
<div class="flex items-center gap-2">
<Globe size={14} class="text-base-content/40 flex-shrink-0" />
<a href="{listing.website.startsWith('http') ? listing.website : 'https://' + listing.website}" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline truncate">{listing.website}</a>
</div>
{/if}
{#if listing.town}
<div class="flex items-center gap-2">
<MapPin size={14} class="text-base-content/40 flex-shrink-0" />
<span class="text-base-content/70">{listing.town}{county ? `, ${county.state}` : ''}</span>
</div>
{/if}
</div>
{#if listing.phone}
<a href="tel:{listing.phone}" class="btn btn-success gap-2 mt-4 w-full">
<Phone size={16} />Call Now
</a>
{/if}
</div>
</div>
<!-- Social Links -->
{#if listing.facebook_url || listing.instagram_url || listing.google_business_url}
<div class="card bg-base-100 shadow-md border border-base-300/50">
<div class="card-body p-5">
<h2 class="text-lg font-bold text-base-content mb-3">Follow</h2>
<div class="flex flex-col gap-2">
{#if listing.facebook_url}
<a href="{listing.facebook_url.startsWith('http') ? listing.facebook_url : 'https://' + listing.facebook_url}" target="_blank" rel="noopener noreferrer" class="btn btn-outline btn-sm gap-2 justify-start"><ExternalLink size={14} />Facebook</a>
{/if}
{#if listing.instagram_url}
<a href="{listing.instagram_url.startsWith('http') ? listing.instagram_url : 'https://' + listing.instagram_url}" target="_blank" rel="noopener noreferrer" class="btn btn-outline btn-sm gap-2 justify-start"><ExternalLink size={14} />Instagram</a>
{/if}
{#if listing.google_business_url}
<a href="{listing.google_business_url.startsWith('http') ? listing.google_business_url : 'https://' + listing.google_business_url}" target="_blank" rel="noopener noreferrer" class="btn btn-outline btn-sm gap-2 justify-start"><ExternalLink size={14} />Google Business</a>
{/if}
</div>
</div>
</div>
{/if}
</div>
<!-- Right content -->
<div class="lg:col-span-2 space-y-6">
<!-- Service Details -->
<div class="card bg-base-100 shadow-md border border-base-300/50">
<div class="card-body p-5">
<h2 class="text-lg font-bold text-base-content flex items-center gap-2 mb-4">
<Wrench size={18} class="text-success" />Service Details
</h2>
{#if listing.description}
<p class="text-base-content/70 mb-4">{listing.description}</p>
{/if}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
{#if listing.twenty_four_hour}
<div class="flex items-center gap-2 p-3 rounded-lg bg-success/5 border border-success/10">
<Clock size={16} class="text-success" />
<div>
<div class="font-medium text-success">24/7 Availability</div>
<div class="text-base-content/60">Round-the-clock service</div>
</div>
</div>
{/if}
{#if listing.emergency_service}
<div class="flex items-center gap-2 p-3 rounded-lg bg-warning/5 border border-warning/10">
<AlertCircle size={16} class="text-warning" />
<div>
<div class="font-medium text-warning">Emergency Service</div>
<div class="text-base-content/60">Same-day emergency response</div>
</div>
</div>
{/if}
{#if listing.licensed_insured}
<div class="flex items-center gap-2 p-3 rounded-lg bg-info/5 border border-info/10">
<Shield size={16} class="text-info" />
<div>
<div class="font-medium text-info">Licensed & Insured</div>
<div class="text-base-content/60">Fully licensed and insured</div>
</div>
</div>
{/if}
{#if listing.years_experience}
<div class="flex items-center gap-2 p-3 rounded-lg bg-base-200/50">
<Building2 size={16} class="text-base-content/40" />
<div>
<div class="font-medium text-base-content">Experience</div>
<div class="text-base-content/60">{listing.years_experience}+ years in business</div>
</div>
</div>
{/if}
{#if listing.service_area}
<div class="flex items-center gap-2 p-3 rounded-lg bg-base-200/50 sm:col-span-2">
<MapPin size={16} class="text-base-content/40" />
<div>
<div class="font-medium text-base-content">Service Area</div>
<div class="text-base-content/60">{listing.service_area}</div>
</div>
</div>
{/if}
</div>
</div>
</div>
<!-- Additional Towns Serviced -->
{#if listing.towns_serviced && listing.towns_serviced.length > 0}
<div class="card bg-base-100 shadow-md border border-base-300/50">
<div class="card-body p-5">
<h2 class="text-lg font-bold text-base-content flex items-center gap-2 mb-4">
<MapPin size={18} class="text-success" />Also Servicing
</h2>
<div class="flex flex-wrap gap-2">
{#each listing.towns_serviced as town}
<span class="badge badge-outline badge-lg">{town}</span>
{/each}
</div>
</div>
</div>
{/if}
</div>
</div>
</div>
{/if}
{/if}

View File

@@ -1,15 +1,21 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import type { Listing, Company } from '$lib/api';
import { PUBLIC_API_URL } from '$env/static/public';
import type { Listing, Company, ServiceListing, Subscription } from '$lib/api';
let listings: Listing[] = [];
let serviceListings: ServiceListing[] = [];
let company: Company | null = null;
let isLoading = true;
let serviceLoading = true;
let companyLoading = true;
let error = '';
let serviceError = '';
let companyError = '';
let successMessage = '';
let subscription: Subscription | null = null;
let subscriptionLoading = true;
// Inline editing state
let editingId: number | null = null;
@@ -34,7 +40,7 @@
async function fetchListings() {
const result = await api.listing.getAll();
if (result.error) {
if (result.error !== 'Session expired. Please log in again.') {
error = 'Failed to load listings';
@@ -45,6 +51,28 @@
isLoading = false;
}
async function fetchServiceListings() {
const result = await api.serviceListing.getAll();
if (result.error) {
if (result.error !== 'Session expired. Please log in again.') {
serviceError = 'Failed to load service listings';
}
} else {
serviceListings = result.data || [];
}
serviceLoading = false;
}
async function deleteServiceListing(id: number) {
if (!confirm('Are you sure you want to delete this service listing?')) return;
const result = await api.serviceListing.delete(id);
if (result.error) {
alert('Failed to delete service listing');
} else {
serviceListings = serviceListings.filter(l => l.id !== id);
}
}
async function deleteListing(id: number) {
if (!confirm('Are you sure you want to delete this listing?')) return;
@@ -152,6 +180,25 @@
return phone;
}
async function fetchSubscription() {
const result = await api.subscription.get();
if (result.error) {
// Subscription not found is okay (no company yet)
subscription = null;
} else {
subscription = result.data;
}
subscriptionLoading = false;
}
function getTrialDaysRemaining(): number {
if (!subscription) return 0;
const end = new Date(subscription.trial_end);
const now = new Date();
const diff = end.getTime() - now.getTime();
return Math.max(0, Math.ceil(diff / (1000 * 60 * 60 * 24)));
}
onMount(() => {
if (!api.auth.isAuthenticated()) {
window.location.href = '/login';
@@ -159,11 +206,21 @@
}
fetchCompany();
fetchListings();
fetchServiceListings();
fetchSubscription();
});
</script>
<div class="container mx-auto p-4">
<h1 class="text-3xl font-bold mb-6">Vendor Dashboard</h1>
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-6 gap-4">
<h1 class="text-3xl font-bold">Vendor Dashboard</h1>
<div class="px-4 py-2 bg-info/10 text-info-content rounded-lg border border-info/20 flex items-center gap-2 shadow-sm">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 text-info">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.25 9.75v-4.5m0 4.5h4.5m-4.5 0 6-6m-3 18c-8.284 0-15-6.716-15-15V4.5A2.25 2.25 0 0 1 4.5 2.25h1.372c.516 0 .966.351 1.091.852l1.106 4.423c.11.44-.054.902-.417 1.173l-1.293.97a17.14 17.14 0 0 0 7.043 7.043l.97-1.293c.271-.363.734-.527 1.173-.417l4.423 1.106c.5.125.852.575.852 1.091V19.5a2.25 2.25 0 0 1-2.25 2.25h-2.25Z" />
</svg>
<span class="font-medium text-sm">Need help or support? Call <a href="tel:7743342638" class="font-bold hover:underline">774 334 2638</a></span>
</div>
</div>
<!-- Success Banner -->
{#if successMessage}
@@ -256,12 +313,50 @@
<!-- Navigation Links -->
<div class="flex flex-wrap gap-4 mb-8">
<a href="/vendor/profile" class="btn btn-outline">Company Profile</a>
<a href="/vendor/listing" class="btn btn-primary">Create Listing</a>
<a href="/vendor/listing" class="btn btn-primary">Add Oil Company Listing</a>
<a href="/vendor/service" class="btn btn-success">Add Service Listing</a>
</div>
<!-- Subscription Status Card -->
{#if !subscriptionLoading && subscription}
{@const daysRemaining = getTrialDaysRemaining()}
<div class="card bg-base-100 shadow-xl mb-8 border-2 {subscription.status === 'trial' ? 'border-warning/30' : 'border-success/30'}">
<div class="card-body p-5">
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<h2 class="text-lg font-bold text-base-content flex items-center gap-2">
🔥 Subscription Status
{#if subscription.status === 'trial'}
<span class="badge badge-warning">Free Trial</span>
{:else if subscription.status === 'active'}
<span class="badge badge-success">Active</span>
{:else}
<span class="badge badge-error">{subscription.status}</span>
{/if}
</h2>
{#if subscription.status === 'trial'}
<p class="text-sm text-base-content/60 mt-1">
Your 1 year free trial {daysRemaining > 0 ? `ends on ${new Date(subscription.trial_end).toLocaleDateString()}` : 'has expired'}.
{#if daysRemaining > 0}
<span class="font-semibold text-primary">{daysRemaining} days remaining</span>
{/if}
</p>
{/if}
</div>
{#if subscription.status === 'trial' && daysRemaining > 0}
<div class="text-center px-4 py-2 rounded-xl bg-warning/10">
<div class="text-2xl font-bold text-warning">{daysRemaining}</div>
<div class="text-xs text-base-content/50">days left</div>
</div>
{/if}
</div>
</div>
</div>
{/if}
<!-- Listings Table -->
<div class="mt-8">
<h2 class="text-2xl font-bold mb-4">Your Listings</h2>
<h2 class="text-2xl font-bold mb-4">Your Oil Company Listings</h2>
{#if isLoading}
<div class="flex justify-center">
@@ -273,8 +368,8 @@
</div>
{:else if listings.length === 0}
<div class="text-center py-8">
<p class="text-lg text-gray-600">No listings found.. create one :)</p>
<a href="/vendor/listing" class="btn btn-primary mt-4">Create Listing</a>
<p class="text-lg text-gray-600">No oil listings found. Create one to get started.</p>
<a href="/vendor/listing" class="btn btn-primary mt-4">Add Oil Company Listing</a>
</div>
{:else}
<div class="overflow-x-auto">
@@ -296,7 +391,18 @@
<tbody>
{#each listings.filter(l => l.phone) as listing}
<tr>
<td>{listing.company_name}</td>
<td>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full overflow-hidden bg-primary flex items-center justify-center flex-shrink-0">
{#if listing.logo_url}
<img src="{PUBLIC_API_URL}{listing.logo_url}" alt="" class="w-full h-full object-cover" />
{:else}
<span class="text-sm text-primary-content font-bold">{listing.company_name.charAt(0).toUpperCase()}</span>
{/if}
</div>
<span>{listing.company_name}</span>
</div>
</td>
<td>
{#if listing.town}
{listing.town} (ID: {listing.county_id})
@@ -458,4 +564,114 @@
</div>
{/if}
</div>
<!-- Service Listings Section -->
<div class="mt-12">
<div class="flex items-center justify-between mb-4">
<h2 class="text-2xl font-bold">Your Service Listings</h2>
<a href="/vendor/service" class="btn btn-success btn-sm">+ Add Service Listing</a>
</div>
{#if serviceLoading}
<div class="flex justify-center">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else if serviceError}
<div class="alert alert-error">
<span>{serviceError}</span>
</div>
{:else if serviceListings.length === 0}
<div class="text-center py-8 bg-base-200/50 rounded-xl border border-base-300/60">
<p class="text-lg text-base-content/60 mb-2">No service listings yet.</p>
<p class="text-sm text-base-content/40 mb-4">Add a service listing if you offer boiler/furnace repair and maintenance.</p>
<a href="/vendor/service" class="btn btn-success btn-sm">Add Service Listing</a>
</div>
{:else}
<div class="overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
<th>Company</th>
<th>Location</th>
<th>Phone</th>
<th>24hr</th>
<th>Emergency</th>
<th>Licensed</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#each serviceListings as listing}
<tr>
<td>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full overflow-hidden bg-success flex items-center justify-center flex-shrink-0">
{#if listing.logo_url}
<img src="{PUBLIC_API_URL}{listing.logo_url}" alt="" class="w-full h-full object-cover" />
{:else}
<span class="text-sm text-success-content font-bold">{listing.company_name.charAt(0).toUpperCase()}</span>
{/if}
</div>
<div>
<div class="font-medium">{listing.company_name}</div>
{#if listing.service_area}
<div class="text-xs text-base-content/50">{listing.service_area}</div>
{/if}
</div>
</div>
</td>
<td>
{listing.town || ''}
{#if listing.county_id}
<span class="text-xs text-base-content/40">(County {listing.county_id})</span>
{/if}
</td>
<td>{listing.phone || '—'}</td>
<td>
{#if listing.twenty_four_hour}
<span class="badge badge-success badge-sm">24hr</span>
{:else}
<span class="badge badge-neutral badge-sm">No</span>
{/if}
</td>
<td>
{#if listing.emergency_service}
<span class="badge badge-warning badge-sm">Yes</span>
{:else}
<span class="badge badge-neutral badge-sm">No</span>
{/if}
</td>
<td>
{#if listing.licensed_insured}
<span class="badge badge-info badge-sm">Yes</span>
{:else}
<span class="badge badge-neutral badge-sm">No</span>
{/if}
</td>
<td>
{#if listing.is_active}
<span class="badge badge-success">Active</span>
{:else}
<span class="badge badge-neutral">Inactive</span>
{/if}
</td>
<td>
<div class="flex gap-2">
<a href="/vendor/service/{listing.id}" class="btn btn-sm btn-primary">Edit</a>
<button
class="btn btn-sm btn-error"
on:click={() => deleteServiceListing(listing.id)}
>
Delete
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</div>

View File

@@ -16,9 +16,23 @@
state: '',
countyId: 0,
town: '',
url: ''
url: '',
townsServiced: [] as string[]
};
let newTown = '';
function addTown() {
if (newTown.trim() && !formData.townsServiced.includes(newTown.trim())) {
formData.townsServiced = [...formData.townsServiced, newTown.trim()];
newTown = '';
}
}
function removeTown(townToRemove: string) {
formData.townsServiced = formData.townsServiced.filter(town => town !== townToRemove);
}
// Active status
let isActive = true;
@@ -146,7 +160,8 @@
county_id: formData.countyId,
town: formData.town.trim() || null,
url: formData.url.trim() || null
url: formData.url.trim() || null,
towns_serviced: formData.townsServiced.length > 0 ? formData.townsServiced : null
});
if (result.error) {
@@ -249,10 +264,10 @@
</label>
<input
id="url"
type="url"
type="text"
class="input input-bordered w-full"
bind:value={formData.url}
placeholder="https://example.com"
placeholder="example.com"
/>
</div>
@@ -444,6 +459,40 @@
<span class="label-text-alt">Specify the town within the selected county</span>
</label>
</div>
<!-- Additional Towns Serviced Input -->
<div class="form-control">
<label class="label" for="newTown">
<span class="label-text">Additional Towns Serviced (Optional)</span>
</label>
<div class="flex gap-2">
<input
id="newTown"
type="text"
class="input input-bordered flex-1"
bind:value={newTown}
on:keydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addTown(); } }}
placeholder="Type a town and press Add"
/>
<button type="button" class="btn btn-secondary" on:click={addTown}>Add</button>
</div>
<label class="label">
<span class="label-text-alt">Add any other towns your company services</span>
</label>
{#if formData.townsServiced.length > 0}
<div class="flex flex-wrap gap-2 mt-2">
{#each formData.townsServiced as town}
<div class="badge badge-primary gap-1 p-3">
{town}
<button type="button" class="hover:text-error" aria-label="Remove {town}" on:click={() => removeTown(town)}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="inline-block w-4 h-4 stroke-current"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
</button>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>

View File

@@ -4,6 +4,9 @@
import { page } from '$app/stores';
import { newEnglandStates } from '$lib/states';
import { api } from '$lib/api';
import { PUBLIC_API_URL } from '$env/static/public';
import ImageCropModal from '$lib/components/ImageCropModal.svelte';
import BannerCropModal from '$lib/components/BannerCropModal.svelte';
import type { County, Listing } from '$lib/api';
// Get listing ID from URL params
@@ -22,9 +25,26 @@
state: '',
countyId: 0,
town: '',
url: ''
url: '',
facebookUrl: '',
instagramUrl: '',
googleBusinessUrl: '',
townsServiced: [] as string[]
};
let newTown = '';
function addTown() {
if (newTown.trim() && !formData.townsServiced.includes(newTown.trim())) {
formData.townsServiced = [...formData.townsServiced, newTown.trim()];
newTown = '';
}
}
function removeTown(townToRemove: string) {
formData.townsServiced = formData.townsServiced.filter(town => town !== townToRemove);
}
// Active status
let isActive = true;
@@ -52,6 +72,20 @@
let submitMessage = '';
let isLoading = true;
// Image upload state
let currentLogoUrl: string | null = null;
let imageUploading = false;
let imageError = '';
let showCropModal = false;
let cropSrc = '';
// Banner upload state
let currentBannerUrl: string | null = null;
let bannerUploading = false;
let bannerError = '';
let showBannerCropModal = false;
let bannerCropSrc = '';
// Location selector state
let counties: County[] = [];
let isLoadingCounties = false;
@@ -80,6 +114,12 @@
formData.countyId = listing.county_id;
formData.town = listing.town || '';
formData.url = listing.url || '';
formData.facebookUrl = listing.facebook_url || '';
formData.instagramUrl = listing.instagram_url || '';
formData.googleBusinessUrl = listing.google_business_url || '';
formData.townsServiced = listing.towns_serviced || [];
currentLogoUrl = listing.logo_url;
currentBannerUrl = listing.banner_url || null;
// Load the state for this county
await loadStateForCounty(listing.county_id);
@@ -203,7 +243,11 @@
online_ordering: onlineOrdering,
county_id: formData.countyId,
town: formData.town.trim() || null,
url: formData.url.trim() || null
url: formData.url.trim() || null,
facebook_url: formData.facebookUrl.trim() || null,
instagram_url: formData.instagramUrl.trim() || null,
google_business_url: formData.googleBusinessUrl.trim() || null,
towns_serviced: formData.townsServiced.length > 0 ? formData.townsServiced : null
});
if (result.error) {
@@ -243,11 +287,120 @@
}
}
function handleImageSelect(event: Event) {
const input = event.target as HTMLInputElement;
if (!input.files || input.files.length === 0) return;
const file = input.files[0];
input.value = '';
imageError = '';
const reader = new FileReader();
reader.onload = (e) => {
cropSrc = e.target?.result as string;
showCropModal = true;
};
reader.readAsDataURL(file);
}
async function handleCrop(event: CustomEvent<Blob>) {
const blob = event.detail;
showCropModal = false;
cropSrc = '';
imageError = '';
imageUploading = true;
const file = new File([blob], 'logo.jpg', { type: 'image/jpeg' });
const result = await api.upload.uploadListingImage(parseInt(listingId), file);
if (result.error) {
imageError = result.error;
} else if (result.data) {
currentLogoUrl = result.data.logo_url;
}
imageUploading = false;
}
function handleCropCancel() {
showCropModal = false;
cropSrc = '';
}
async function handleImageDelete() {
if (!confirm('Remove the company logo?')) return;
imageError = '';
imageUploading = true;
const result = await api.upload.deleteListingImage(parseInt(listingId));
if (result.error) {
imageError = result.error;
} else {
currentLogoUrl = null;
}
imageUploading = false;
}
function handleBannerSelect(event: Event) {
const input = event.target as HTMLInputElement;
if (!input.files || input.files.length === 0) return;
const file = input.files[0];
input.value = '';
bannerError = '';
const reader = new FileReader();
reader.onload = (e) => {
bannerCropSrc = e.target?.result as string;
showBannerCropModal = true;
};
reader.readAsDataURL(file);
}
async function handleBannerCrop(event: CustomEvent<Blob>) {
const blob = event.detail;
showBannerCropModal = false;
bannerCropSrc = '';
bannerError = '';
bannerUploading = true;
const file = new File([blob], 'banner.jpg', { type: 'image/jpeg' });
const result = await api.upload.uploadListingBanner(parseInt(listingId), file);
if (result.error) {
bannerError = result.error;
} else if (result.data) {
currentBannerUrl = result.data.banner_url;
}
bannerUploading = false;
}
function handleBannerCropCancel() {
showBannerCropModal = false;
bannerCropSrc = '';
}
async function handleBannerDelete() {
if (!confirm('Remove the banner image?')) return;
bannerError = '';
bannerUploading = true;
const result = await api.upload.deleteListingBanner(parseInt(listingId));
if (result.error) {
bannerError = result.error;
} else {
currentBannerUrl = null;
}
bannerUploading = false;
}
onMount(() => {
loadListing();
});
</script>
<ImageCropModal open={showCropModal} imageSrc={cropSrc} on:crop={handleCrop} on:cancel={handleCropCancel} />
<BannerCropModal open={showBannerCropModal} imageSrc={bannerCropSrc} on:crop={handleBannerCrop} on:cancel={handleBannerCropCancel} />
<!-- Breadcrumbs (full width, left aligned) -->
<nav class="breadcrumbs text-sm mb-4 px-4">
<ul>
@@ -270,6 +423,114 @@
<a href="/vendor" class="text-blue-500 hover:underline">Back to Vendor Dashboard</a>
</div>
<!-- Company Logo Card -->
<div class="card bg-base-100 shadow-xl mb-8">
<div class="card-body">
<h2 class="card-title text-xl mb-4">Company Logo</h2>
<div class="flex flex-col items-center gap-4">
<!-- Logo preview 170×170 -->
<div class="w-[170px] h-[170px] rounded-full overflow-hidden bg-primary flex items-center justify-center flex-shrink-0">
{#if currentLogoUrl}
<img src="{PUBLIC_API_URL}{currentLogoUrl}" alt="Company logo" class="w-full h-full object-cover" />
{:else}
<span class="text-5xl text-primary-content font-bold">
{formData.companyName ? formData.companyName.charAt(0).toUpperCase() : '?'}
</span>
{/if}
</div>
{#if imageError}
<div class="alert alert-error w-full">
<span>{imageError}</span>
</div>
{/if}
<div class="flex flex-wrap gap-2 justify-center">
<label class="btn btn-primary {imageUploading ? 'btn-disabled' : ''}">
{#if imageUploading}
<span class="loading loading-spinner loading-sm"></span>
Uploading...
{:else}
Upload Logo
{/if}
<input
type="file"
accept="image/jpeg,image/png,image/webp"
class="hidden"
on:change={handleImageSelect}
disabled={imageUploading}
/>
</label>
{#if currentLogoUrl}
<button
type="button"
class="btn btn-error btn-outline"
on:click={handleImageDelete}
disabled={imageUploading}
>
Remove Logo
</button>
{/if}
</div>
<p class="text-sm text-base-content/50 text-center">JPEG, PNG, or WebP &bull; Max 5MB &bull; Displayed at 170&times;170px</p>
</div>
</div>
</div>
<!-- Company Banner Card -->
<div class="card bg-base-100 shadow-xl mb-8">
<div class="card-body">
<h2 class="card-title text-xl mb-4">Mobile Banner Image</h2>
<div class="flex flex-col items-center gap-4">
<!-- Banner preview 750×250 (scaled with CSS for view) -->
<div class="w-full max-w-[400px] aspect-[3/1] rounded-lg overflow-hidden bg-primary flex items-center justify-center flex-shrink-0">
{#if currentBannerUrl}
<img src="{PUBLIC_API_URL}{currentBannerUrl}" alt="Mobile banner" class="w-full h-full object-cover" />
{:else}
<span class="text-3xl text-primary-content font-bold">
{formData.companyName ? formData.companyName.charAt(0).toUpperCase() : '?'}
</span>
{/if}
</div>
{#if bannerError}
<div class="alert alert-error w-full">
<span>{bannerError}</span>
</div>
{/if}
<div class="flex flex-wrap gap-2 justify-center">
<label class="btn btn-primary {bannerUploading ? 'btn-disabled' : ''}">
{#if bannerUploading}
<span class="loading loading-spinner loading-sm"></span>
Uploading...
{:else}
Upload Banner
{/if}
<input
type="file"
accept="image/jpeg,image/png,image/webp"
class="hidden"
on:change={handleBannerSelect}
disabled={bannerUploading}
/>
</label>
{#if currentBannerUrl}
<button
type="button"
class="btn btn-error btn-outline"
on:click={handleBannerDelete}
disabled={bannerUploading}
>
Remove Banner
</button>
{/if}
</div>
<p class="text-sm text-base-content/50 text-center">JPEG, PNG, or WebP &bull; Max 5MB &bull; Displayed at top of mobile listings (3:1 wide format)</p>
</div>
</div>
</div>
<!-- Edit Listing Form -->
<div class="card bg-base-100 shadow-xl mb-8">
<div class="card-body">
@@ -315,10 +576,10 @@
</label>
<input
id="url"
type="url"
type="text"
class="input input-bordered w-full"
bind:value={formData.url}
placeholder="https://example.com"
placeholder="example.com"
/>
</div>
@@ -510,6 +771,40 @@
<span class="label-text-alt">Specify the town within the selected county</span>
</label>
</div>
<!-- Additional Towns Serviced Input -->
<div class="form-control">
<label class="label" for="newTown">
<span class="label-text">Additional Towns Serviced (Optional)</span>
</label>
<div class="flex gap-2">
<input
id="newTown"
type="text"
class="input input-bordered flex-1"
bind:value={newTown}
on:keydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addTown(); } }}
placeholder="Type a town and press Add"
/>
<button type="button" class="btn btn-secondary" on:click={addTown}>Add</button>
</div>
<label class="label">
<span class="label-text-alt">Add any other towns your company services</span>
</label>
{#if formData.townsServiced.length > 0}
<div class="flex flex-wrap gap-2 mt-2">
{#each formData.townsServiced as town}
<div class="badge badge-primary gap-1 p-3">
{town}
<button type="button" class="hover:text-error" aria-label="Remove {town}" on:click={() => removeTown(town)}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="inline-block w-4 h-4 stroke-current"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
</button>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
@@ -560,6 +855,54 @@
</div>
</div>
<!-- Social Media & Business Links Section -->
<div class="bg-base-200 p-4 rounded-lg">
<h3 class="text-lg font-semibold mb-3">Social Media & Business Links</h3>
<div class="space-y-4">
<!-- Facebook URL -->
<div class="form-control">
<label class="label" for="facebookUrl">
<span class="label-text">Facebook Page URL (Optional)</span>
</label>
<input
id="facebookUrl"
type="text"
class="input input-bordered w-full"
bind:value={formData.facebookUrl}
placeholder="facebook.com/yourpage"
/>
</div>
<!-- Instagram URL -->
<div class="form-control">
<label class="label" for="instagramUrl">
<span class="label-text">Instagram URL (Optional)</span>
</label>
<input
id="instagramUrl"
type="text"
class="input input-bordered w-full"
bind:value={formData.instagramUrl}
placeholder="instagram.com/yourpage"
/>
</div>
<!-- Google Business URL -->
<div class="form-control">
<label class="label" for="googleBusinessUrl">
<span class="label-text">Google Business Page URL (Optional)</span>
</label>
<input
id="googleBusinessUrl"
type="text"
class="input input-bordered w-full"
bind:value={formData.googleBusinessUrl}
placeholder="g.page/r/... or similar"
/>
</div>
</div>
</div>
<!-- Submit Button -->
<div class="form-control mt-6">
<button

View File

@@ -0,0 +1,448 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { newEnglandStates } from '$lib/states';
import { api } from '$lib/api';
import type { County } from '$lib/api';
let formData = {
companyName: '',
phone: '',
website: '',
email: '',
town: '',
state: '',
countyId: 0,
description: '',
serviceArea: '',
yearsExperience: null as number | null,
townsServiced: [] as string[]
};
let newTown = '';
function addTown() {
if (newTown.trim() && !formData.townsServiced.includes(newTown.trim())) {
formData.townsServiced = [...formData.townsServiced, newTown.trim()];
newTown = '';
}
}
function removeTown(townToRemove: string) {
formData.townsServiced = formData.townsServiced.filter(town => town !== townToRemove);
}
let isActive = true;
let twentyFourHour = false;
let emergencyService = false;
let licensedInsured = false;
let errors = {
companyName: '',
phone: '',
state: '',
countyId: '',
town: '',
description: '',
};
let isSubmitting = false;
let submitMessage = '';
let counties: County[] = [];
let isLoadingCounties = false;
function formatPhone(value: string): string {
const digits = value.replace(/\D/g, '');
if (digits.length <= 3) return digits;
if (digits.length <= 6) return `${digits.slice(0, 3)}-${digits.slice(3)}`;
return `${digits.slice(0, 3)}-${digits.slice(3, 6)}-${digits.slice(6, 10)}`;
}
function handlePhoneInput(event: Event) {
const target = event.target as HTMLInputElement;
formData.phone = formatPhone(target.value);
}
function validateForm(): boolean {
errors = { companyName: '', phone: '', state: '', countyId: '', town: '', description: '' };
let isValid = true;
if (!formData.companyName.trim()) {
errors.companyName = 'Company name is required';
isValid = false;
}
const phonePattern = /^[0-9]{3}-[0-9]{3}-[0-9]{4}$/;
if (formData.phone && !phonePattern.test(formData.phone)) {
errors.phone = 'Phone number must be in format 123-456-7890';
isValid = false;
}
if (!formData.state) {
errors.state = 'Please select a state';
isValid = false;
}
if (!formData.countyId) {
errors.countyId = 'Please select a county';
isValid = false;
}
if (!formData.town.trim()) {
errors.town = 'Town is required';
isValid = false;
}
if (formData.description && formData.description.length > 500) {
errors.description = 'Description cannot exceed 500 characters';
isValid = false;
}
return isValid;
}
async function handleSubmit(event: Event) {
event.preventDefault();
if (!validateForm()) return;
isSubmitting = true;
submitMessage = '';
const result = await api.serviceListing.create({
company_name: formData.companyName,
is_active: isActive,
twenty_four_hour: twentyFourHour,
emergency_service: emergencyService,
town: formData.town.trim() || null,
county_id: formData.countyId,
phone: formData.phone.trim() || null,
website: formData.website.trim() || null,
email: formData.email.trim() || null,
description: formData.description.trim() || null,
licensed_insured: licensedInsured,
service_area: formData.serviceArea.trim() || null,
years_experience: formData.yearsExperience,
towns_serviced: formData.townsServiced.length > 0 ? formData.townsServiced : null
});
if (result.error) {
submitMessage = result.error === 'Session expired. Please log in again.'
? result.error
: 'Failed to create service listing. Please try again.';
} else {
goto('/vendor');
}
isSubmitting = false;
}
async function handleStateChange() {
formData.countyId = 0;
errors.state = '';
errors.countyId = '';
counties = [];
if (formData.state) {
isLoadingCounties = true;
const result = await api.state.getCounties(formData.state);
if (result.error) {
errors.countyId = result.error;
} else {
counties = result.data || [];
}
isLoadingCounties = false;
}
}
</script>
<nav class="breadcrumbs text-sm mb-4 px-4">
<ul>
<li><a href="/" class="text-blue-500 hover:underline">Home</a></li>
<li><a href="/vendor" class="text-blue-500 hover:underline">Vendor Dashboard</a></li>
<li>Add Service Listing</li>
</ul>
</nav>
<div class="p-4 max-w-2xl mx-auto">
<div class="card bg-base-100 shadow-xl mb-8">
<div class="card-body">
<h2 class="card-title text-xl mb-1">Add Service Company Listing</h2>
<p class="text-base-content/60 text-sm mb-4">List your heating/boiler service company so customers in need can find you.</p>
<form on:submit={handleSubmit} class="space-y-6">
<!-- Active -->
<div class="form-control">
<label class="label cursor-pointer">
<span class="label-text">Active (visible to customers)</span>
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={isActive} />
</label>
</div>
<!-- Company Name -->
<div class="form-control">
<label class="label" for="companyName">
<span class="label-text">Company Name <span class="text-error">*</span></span>
</label>
<input
id="companyName"
type="text"
class="input input-bordered w-full {errors.companyName ? 'input-error' : ''}"
bind:value={formData.companyName}
placeholder="Enter company name"
required
/>
{#if errors.companyName}
<label class="label"><span class="label-text-alt text-error">{errors.companyName}</span></label>
{/if}
</div>
<!-- Service Flags -->
<div class="bg-base-200 p-4 rounded-lg">
<h3 class="text-lg font-semibold mb-3">Service Capabilities</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" class="checkbox checkbox-success" bind:checked={twentyFourHour} />
<span class="label-text">24-Hour Service</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" class="checkbox checkbox-warning" bind:checked={emergencyService} />
<span class="label-text">Emergency Service</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={licensedInsured} />
<span class="label-text">Licensed &amp; Insured</span>
</label>
</div>
</div>
<!-- Description -->
<div class="form-control">
<label class="label" for="description">
<span class="label-text">Description of Services</span>
<span class="label-text-alt text-base-content/50">{formData.description.length}/500</span>
</label>
<textarea
id="description"
class="textarea textarea-bordered w-full {errors.description ? 'textarea-error' : ''}"
bind:value={formData.description}
placeholder="e.g. Boiler repairs, furnace tune-ups, emergency no-heat calls, oil burner service..."
maxlength="500"
rows="3"
></textarea>
{#if errors.description}
<label class="label"><span class="label-text-alt text-error">{errors.description}</span></label>
{/if}
</div>
<!-- Experience -->
<div class="form-control">
<label class="label" for="yearsExperience">
<span class="label-text">Years in Business (Optional)</span>
</label>
<input
id="yearsExperience"
type="number"
min="0"
max="200"
class="input input-bordered w-full"
bind:value={formData.yearsExperience}
placeholder="e.g. 25"
/>
</div>
<!-- Location -->
<div class="bg-base-200 p-4 rounded-lg">
<h3 class="text-lg font-semibold mb-3">Location</h3>
<div class="space-y-4">
<!-- State -->
<div class="form-control">
<label class="label" for="state">
<span class="label-text">State <span class="text-error">*</span></span>
</label>
<select
id="state"
class="select select-bordered w-full {errors.state ? 'select-error' : ''}"
bind:value={formData.state}
on:change={handleStateChange}
required
>
<option value="">Select State</option>
{#each newEnglandStates as state}
<option value={state.id}>{state.name}</option>
{/each}
</select>
{#if errors.state}
<label class="label"><span class="label-text-alt text-error">{errors.state}</span></label>
{/if}
</div>
<!-- County -->
<div class="form-control">
<label class="label" for="county">
<span class="label-text">County <span class="text-error">*</span></span>
</label>
<select
id="county"
class="select select-bordered w-full {errors.countyId ? 'select-error' : ''}"
bind:value={formData.countyId}
disabled={!formData.state || isLoadingCounties}
required
>
<option value={0}>
{#if isLoadingCounties}Loading counties...{:else}Select County{/if}
</option>
{#each counties as county}
<option value={county.id}>{county.name}</option>
{/each}
</select>
{#if errors.countyId}
<label class="label"><span class="label-text-alt text-error">{errors.countyId}</span></label>
{/if}
</div>
<!-- Town -->
<div class="form-control">
<label class="label" for="town">
<span class="label-text">Town <span class="text-error">*</span></span>
</label>
<input
id="town"
type="text"
required
class="input input-bordered w-full {errors.town ? 'input-error' : ''}"
bind:value={formData.town}
placeholder="Enter town name"
maxlength="100"
/>
{#if errors.town}
<label class="label"><span class="label-text-alt text-error">{errors.town}</span></label>
{/if}
</div>
<!-- Additional Towns Serviced Input -->
<div class="form-control">
<label class="label" for="newTown">
<span class="label-text">Additional Towns Serviced (Optional)</span>
</label>
<div class="flex gap-2">
<input
id="newTown"
type="text"
class="input input-bordered flex-1"
bind:value={newTown}
on:keydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addTown(); } }}
placeholder="Type a town and press Add"
/>
<button type="button" class="btn btn-secondary" on:click={addTown}>Add</button>
</div>
<label class="label">
<span class="label-text-alt">Add any other towns your company services</span>
</label>
{#if formData.townsServiced.length > 0}
<div class="flex flex-wrap gap-2 mt-2">
{#each formData.townsServiced as town}
<div class="badge badge-primary gap-1 p-3">
{town}
<button type="button" class="hover:text-error" aria-label="Remove {town}" on:click={() => removeTown(town)}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="inline-block w-4 h-4 stroke-current"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
</button>
</div>
{/each}
</div>
{/if}
</div>
<!-- Service Area -->
<div class="form-control">
<label class="label" for="serviceArea">
<span class="label-text">Service Area (Optional)</span>
</label>
<input
id="serviceArea"
type="text"
class="input input-bordered w-full"
bind:value={formData.serviceArea}
placeholder="e.g. Greater Boston, All of Middlesex County"
maxlength="255"
/>
<label class="label">
<span class="label-text-alt">Describe the broader area you serve</span>
</label>
</div>
</div>
</div>
<!-- Contact -->
<div class="bg-base-200 p-4 rounded-lg">
<h3 class="text-lg font-semibold mb-3">Contact Information</h3>
<div class="space-y-4">
<!-- Phone -->
<div class="form-control">
<label class="label" for="phone">
<span class="label-text">Phone</span>
</label>
<input
id="phone"
type="tel"
class="input input-bordered w-full {errors.phone ? 'input-error' : ''}"
bind:value={formData.phone}
on:input={handlePhoneInput}
placeholder="123-456-7890"
/>
{#if errors.phone}
<label class="label"><span class="label-text-alt text-error">{errors.phone}</span></label>
{/if}
</div>
<!-- Email -->
<div class="form-control">
<label class="label" for="email">
<span class="label-text">Email (Optional)</span>
</label>
<input
id="email"
type="email"
class="input input-bordered w-full"
bind:value={formData.email}
placeholder="service@company.com"
/>
</div>
<!-- Website -->
<div class="form-control">
<label class="label" for="website">
<span class="label-text">Website (Optional)</span>
</label>
<input
id="website"
type="text"
class="input input-bordered w-full"
bind:value={formData.website}
placeholder="example.com"
/>
</div>
</div>
</div>
<!-- Submit -->
<div class="form-control mt-6">
<button type="submit" class="btn btn-success w-full" disabled={isSubmitting}>
{#if isSubmitting}
<span class="loading loading-spinner loading-sm"></span>
Saving...
{:else}
Add Service Listing
{/if}
</button>
</div>
{#if submitMessage}
<div class="alert {submitMessage.includes('successfully') ? 'alert-success' : 'alert-error'}">
<span>{submitMessage}</span>
</div>
{/if}
</form>
</div>
</div>
</div>

View File

@@ -0,0 +1,780 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { newEnglandStates } from '$lib/states';
import { api } from '$lib/api';
import { PUBLIC_API_URL } from '$env/static/public';
import ImageCropModal from '$lib/components/ImageCropModal.svelte';
import BannerCropModal from '$lib/components/BannerCropModal.svelte';
import type { County, ServiceListing } from '$lib/api';
let listingId: string;
$: listingId = $page.params.id;
let formData = {
companyName: '',
phone: '',
website: '',
email: '',
town: '',
state: '',
countyId: 0,
description: '',
serviceArea: '',
yearsExperience: null as number | null,
facebookUrl: '',
instagramUrl: '',
googleBusinessUrl: '',
townsServiced: [] as string[]
};
let newTown = '';
function addTown() {
if (newTown.trim() && !formData.townsServiced.includes(newTown.trim())) {
formData.townsServiced = [...formData.townsServiced, newTown.trim()];
newTown = '';
}
}
function removeTown(townToRemove: string) {
formData.townsServiced = formData.townsServiced.filter(town => town !== townToRemove);
}
let isActive = true;
let twentyFourHour = false;
let emergencyService = false;
let licensedInsured = false;
let errors = {
companyName: '',
phone: '',
state: '',
countyId: '',
town: '',
description: '',
};
let isLoading = true;
let isSubmitting = false;
let submitMessage = '';
let loadError = '';
let counties: County[] = [];
let isLoadingCounties = false;
// Image upload state
let currentLogoUrl: string | null = null;
let imageUploading = false;
let imageError = '';
let showCropModal = false;
let cropSrc = '';
// Banner upload state
let currentBannerUrl: string | null = null;
let bannerUploading = false;
let bannerError = '';
let showBannerCropModal = false;
let bannerCropSrc = '';
function formatPhone(value: string): string {
const digits = value.replace(/\D/g, '');
if (digits.length <= 3) return digits;
if (digits.length <= 6) return `${digits.slice(0, 3)}-${digits.slice(3)}`;
return `${digits.slice(0, 3)}-${digits.slice(3, 6)}-${digits.slice(6, 10)}`;
}
function handlePhoneInput(event: Event) {
const target = event.target as HTMLInputElement;
formData.phone = formatPhone(target.value);
}
async function loadListing() {
const result = await api.serviceListing.getById(parseInt(listingId));
if (result.error) {
loadError = 'Failed to load service listing';
isLoading = false;
return;
}
const listing = result.data as ServiceListing;
formData.companyName = listing.company_name;
formData.phone = listing.phone || '';
formData.website = listing.website || '';
formData.email = listing.email || '';
formData.town = listing.town || '';
formData.countyId = listing.county_id;
formData.description = listing.description || '';
formData.serviceArea = listing.service_area || '';
formData.yearsExperience = listing.years_experience;
formData.facebookUrl = listing.facebook_url || '';
formData.instagramUrl = listing.instagram_url || '';
formData.googleBusinessUrl = listing.google_business_url || '';
formData.townsServiced = listing.towns_serviced || [];
isActive = listing.is_active;
twentyFourHour = listing.twenty_four_hour;
emergencyService = listing.emergency_service;
licensedInsured = listing.licensed_insured;
currentLogoUrl = listing.logo_url;
currentBannerUrl = listing.banner_url || null;
// Find the state for this county
const stateResult = await api.state.getCounty('', listing.county_id.toString());
// Load counties for the state - we need to determine state from county
// Since we don't have the state in service listing, load state counties by trying each state
// Simpler: look up all NE states until we find this county
for (const state of newEnglandStates) {
const countiesResult = await api.state.getCounties(state.id);
if (countiesResult.data) {
const found = countiesResult.data.find(c => c.id === listing.county_id);
if (found) {
formData.state = state.id;
counties = countiesResult.data;
break;
}
}
}
isLoading = false;
}
function validateForm(): boolean {
errors = { companyName: '', phone: '', state: '', countyId: '', town: '', description: '' };
let isValid = true;
if (!formData.companyName.trim()) {
errors.companyName = 'Company name is required';
isValid = false;
}
const phonePattern = /^[0-9]{3}-[0-9]{3}-[0-9]{4}$/;
if (formData.phone && !phonePattern.test(formData.phone)) {
errors.phone = 'Phone number must be in format 123-456-7890';
isValid = false;
}
if (!formData.state) {
errors.state = 'Please select a state';
isValid = false;
}
if (!formData.countyId) {
errors.countyId = 'Please select a county';
isValid = false;
}
if (!formData.town.trim()) {
errors.town = 'Town is required';
isValid = false;
}
if (formData.description && formData.description.length > 500) {
errors.description = 'Description cannot exceed 500 characters';
isValid = false;
}
return isValid;
}
async function handleSubmit(event: Event) {
event.preventDefault();
if (!validateForm()) return;
isSubmitting = true;
submitMessage = '';
const result = await api.serviceListing.update(parseInt(listingId), {
company_name: formData.companyName,
is_active: isActive,
twenty_four_hour: twentyFourHour,
emergency_service: emergencyService,
town: formData.town.trim() || null,
county_id: formData.countyId,
phone: formData.phone.trim() || null,
website: formData.website.trim() || null,
email: formData.email.trim() || null,
description: formData.description.trim() || null,
licensed_insured: licensedInsured,
service_area: formData.serviceArea.trim() || null,
years_experience: formData.yearsExperience,
facebook_url: formData.facebookUrl.trim() || null,
instagram_url: formData.instagramUrl.trim() || null,
google_business_url: formData.googleBusinessUrl.trim() || null,
towns_serviced: formData.townsServiced.length > 0 ? formData.townsServiced : null
});
if (result.error) {
submitMessage = result.error === 'Session expired. Please log in again.'
? result.error
: 'Failed to update service listing. Please try again.';
} else {
goto('/vendor');
}
isSubmitting = false;
}
async function handleStateChange() {
formData.countyId = 0;
errors.state = '';
errors.countyId = '';
counties = [];
if (formData.state) {
isLoadingCounties = true;
const result = await api.state.getCounties(formData.state);
if (result.error) {
errors.countyId = result.error;
} else {
counties = result.data || [];
}
isLoadingCounties = false;
}
}
function handleImageSelect(event: Event) {
const input = event.target as HTMLInputElement;
if (!input.files || input.files.length === 0) return;
const file = input.files[0];
input.value = '';
imageError = '';
const reader = new FileReader();
reader.onload = (e) => {
cropSrc = e.target?.result as string;
showCropModal = true;
};
reader.readAsDataURL(file);
}
async function handleCrop(event: CustomEvent<Blob>) {
const blob = event.detail;
showCropModal = false;
cropSrc = '';
imageError = '';
imageUploading = true;
const file = new File([blob], 'logo.jpg', { type: 'image/jpeg' });
const result = await api.upload.uploadServiceListingImage(parseInt(listingId), file);
if (result.error) {
imageError = result.error;
} else if (result.data) {
currentLogoUrl = result.data.logo_url;
}
imageUploading = false;
}
function handleCropCancel() {
showCropModal = false;
cropSrc = '';
}
async function handleImageDelete() {
if (!confirm('Remove the company logo?')) return;
imageError = '';
imageUploading = true;
const result = await api.upload.deleteServiceListingImage(parseInt(listingId));
if (result.error) {
imageError = result.error;
} else {
currentLogoUrl = null;
}
imageUploading = false;
}
function handleBannerSelect(event: Event) {
const input = event.target as HTMLInputElement;
if (!input.files || input.files.length === 0) return;
const file = input.files[0];
input.value = '';
bannerError = '';
const reader = new FileReader();
reader.onload = (e) => {
bannerCropSrc = e.target?.result as string;
showBannerCropModal = true;
};
reader.readAsDataURL(file);
}
async function handleBannerCrop(event: CustomEvent<Blob>) {
const blob = event.detail;
showBannerCropModal = false;
bannerCropSrc = '';
bannerError = '';
bannerUploading = true;
const file = new File([blob], 'banner.jpg', { type: 'image/jpeg' });
const result = await api.upload.uploadServiceListingBanner(parseInt(listingId), file);
if (result.error) {
bannerError = result.error;
} else if (result.data) {
currentBannerUrl = result.data.banner_url;
}
bannerUploading = false;
}
function handleBannerCropCancel() {
showBannerCropModal = false;
bannerCropSrc = '';
}
async function handleBannerDelete() {
if (!confirm('Remove the banner image?')) return;
bannerError = '';
bannerUploading = true;
const result = await api.upload.deleteServiceListingBanner(parseInt(listingId));
if (result.error) {
bannerError = result.error;
} else {
currentBannerUrl = null;
}
bannerUploading = false;
}
onMount(() => {
if (!api.auth.isAuthenticated()) {
window.location.href = '/login';
return;
}
loadListing();
});
</script>
<ImageCropModal open={showCropModal} imageSrc={cropSrc} on:crop={handleCrop} on:cancel={handleCropCancel} />
<BannerCropModal open={showBannerCropModal} imageSrc={bannerCropSrc} on:crop={handleBannerCrop} on:cancel={handleBannerCropCancel} />
<nav class="breadcrumbs text-sm mb-4 px-4">
<ul>
<li><a href="/" class="text-blue-500 hover:underline">Home</a></li>
<li><a href="/vendor" class="text-blue-500 hover:underline">Vendor Dashboard</a></li>
<li>Edit Service Listing</li>
</ul>
</nav>
<div class="p-4 max-w-2xl mx-auto">
{#if isLoading}
<div class="flex justify-center py-16">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else if loadError}
<div class="alert alert-error mb-4">
<span>{loadError}</span>
</div>
<a href="/vendor" class="btn btn-outline">Back to Dashboard</a>
{:else}
<!-- Company Logo Card -->
<div class="card bg-base-100 shadow-xl mb-8">
<div class="card-body">
<h2 class="card-title text-xl mb-4">Company Logo</h2>
<div class="flex flex-col items-center gap-4">
<div class="w-[170px] h-[170px] rounded-full overflow-hidden bg-success flex items-center justify-center flex-shrink-0">
{#if currentLogoUrl}
<img src="{PUBLIC_API_URL}{currentLogoUrl}" alt="Company logo" class="w-full h-full object-cover" />
{:else}
<span class="text-5xl text-success-content font-bold">
{formData.companyName ? formData.companyName.charAt(0).toUpperCase() : '?'}
</span>
{/if}
</div>
{#if imageError}
<div class="alert alert-error w-full">
<span>{imageError}</span>
</div>
{/if}
<div class="flex flex-wrap gap-2 justify-center">
<label class="btn btn-primary {imageUploading ? 'btn-disabled' : ''}">
{#if imageUploading}
<span class="loading loading-spinner loading-sm"></span>
Uploading...
{:else}
Upload Logo
{/if}
<input
type="file"
accept="image/jpeg,image/png,image/webp"
class="hidden"
on:change={handleImageSelect}
disabled={imageUploading}
/>
</label>
{#if currentLogoUrl}
<button
type="button"
class="btn btn-error btn-outline"
on:click={handleImageDelete}
disabled={imageUploading}
>
Remove Logo
</button>
{/if}
</div>
<p class="text-sm text-base-content/50 text-center">JPEG, PNG, or WebP &bull; Max 5MB &bull; Displayed at 170&times;170px</p>
</div>
</div>
</div>
<!-- Company Banner Card -->
<div class="card bg-base-100 shadow-xl mb-8">
<div class="card-body">
<h2 class="card-title text-xl mb-4">Mobile Banner Image</h2>
<div class="flex flex-col items-center gap-4">
<div class="w-full max-w-[400px] aspect-[3/1] rounded-lg overflow-hidden bg-primary flex items-center justify-center flex-shrink-0">
{#if currentBannerUrl}
<img src="{PUBLIC_API_URL}{currentBannerUrl}" alt="Mobile banner" class="w-full h-full object-cover" />
{:else}
<span class="text-3xl text-primary-content font-bold">
{formData.companyName ? formData.companyName.charAt(0).toUpperCase() : '?'}
</span>
{/if}
</div>
{#if bannerError}
<div class="alert alert-error w-full">
<span>{bannerError}</span>
</div>
{/if}
<div class="flex flex-wrap gap-2 justify-center">
<label class="btn btn-primary {bannerUploading ? 'btn-disabled' : ''}">
{#if bannerUploading}
<span class="loading loading-spinner loading-sm"></span>
Uploading...
{:else}
Upload Banner
{/if}
<input
type="file"
accept="image/jpeg,image/png,image/webp"
class="hidden"
on:change={handleBannerSelect}
disabled={bannerUploading}
/>
</label>
{#if currentBannerUrl}
<button
type="button"
class="btn btn-error btn-outline"
on:click={handleBannerDelete}
disabled={bannerUploading}
>
Remove Banner
</button>
{/if}
</div>
<p class="text-sm text-base-content/50 text-center">JPEG, PNG, or WebP &bull; Max 5MB &bull; Displayed at top of mobile listings (3:1 wide format)</p>
</div>
</div>
</div>
<div class="card bg-base-100 shadow-xl mb-8">
<div class="card-body">
<h2 class="card-title text-xl mb-1">Edit Service Listing</h2>
<form on:submit={handleSubmit} class="space-y-6">
<div class="form-control">
<label class="label cursor-pointer">
<span class="label-text">Active (visible to customers)</span>
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={isActive} />
</label>
</div>
<div class="form-control">
<label class="label" for="companyName">
<span class="label-text">Company Name <span class="text-error">*</span></span>
</label>
<input
id="companyName"
type="text"
class="input input-bordered w-full {errors.companyName ? 'input-error' : ''}"
bind:value={formData.companyName}
required
/>
{#if errors.companyName}
<label class="label"><span class="label-text-alt text-error">{errors.companyName}</span></label>
{/if}
</div>
<div class="bg-base-200 p-4 rounded-lg">
<h3 class="text-lg font-semibold mb-3">Service Capabilities</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" class="checkbox checkbox-success" bind:checked={twentyFourHour} />
<span class="label-text">24-Hour Service</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" class="checkbox checkbox-warning" bind:checked={emergencyService} />
<span class="label-text">Emergency Service</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={licensedInsured} />
<span class="label-text">Licensed &amp; Insured</span>
</label>
</div>
</div>
<div class="form-control">
<label class="label" for="description">
<span class="label-text">Description of Services</span>
<span class="label-text-alt text-base-content/50">{formData.description.length}/500</span>
</label>
<textarea
id="description"
class="textarea textarea-bordered w-full {errors.description ? 'textarea-error' : ''}"
bind:value={formData.description}
maxlength="500"
rows="3"
></textarea>
{#if errors.description}
<label class="label"><span class="label-text-alt text-error">{errors.description}</span></label>
{/if}
</div>
<div class="form-control">
<label class="label" for="yearsExperience">
<span class="label-text">Years in Business (Optional)</span>
</label>
<input
id="yearsExperience"
type="number"
min="0"
max="200"
class="input input-bordered w-full"
bind:value={formData.yearsExperience}
/>
</div>
<div class="bg-base-200 p-4 rounded-lg">
<h3 class="text-lg font-semibold mb-3">Location</h3>
<div class="space-y-4">
<div class="form-control">
<label class="label" for="state">
<span class="label-text">State <span class="text-error">*</span></span>
</label>
<select
id="state"
class="select select-bordered w-full {errors.state ? 'select-error' : ''}"
bind:value={formData.state}
on:change={handleStateChange}
>
<option value="">Select State</option>
{#each newEnglandStates as state}
<option value={state.id}>{state.name}</option>
{/each}
</select>
{#if errors.state}
<label class="label"><span class="label-text-alt text-error">{errors.state}</span></label>
{/if}
</div>
<div class="form-control">
<label class="label" for="county">
<span class="label-text">County <span class="text-error">*</span></span>
</label>
<select
id="county"
class="select select-bordered w-full {errors.countyId ? 'select-error' : ''}"
bind:value={formData.countyId}
disabled={!formData.state || isLoadingCounties}
>
<option value={0}>
{#if isLoadingCounties}Loading...{:else}Select County{/if}
</option>
{#each counties as county}
<option value={county.id}>{county.name}</option>
{/each}
</select>
{#if errors.countyId}
<label class="label"><span class="label-text-alt text-error">{errors.countyId}</span></label>
{/if}
</div>
<div class="form-control">
<label class="label" for="town">
<span class="label-text">Town <span class="text-error">*</span></span>
</label>
<input
id="town"
type="text"
class="input input-bordered w-full {errors.town ? 'input-error' : ''}"
bind:value={formData.town}
maxlength="100"
/>
{#if errors.town}
<label class="label"><span class="label-text-alt text-error">{errors.town}</span></label>
{/if}
</div>
<!-- Additional Towns Serviced Input -->
<div class="form-control">
<label class="label" for="newTown">
<span class="label-text">Additional Towns Serviced (Optional)</span>
</label>
<div class="flex gap-2">
<input
id="newTown"
type="text"
class="input input-bordered flex-1"
bind:value={newTown}
on:keydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addTown(); } }}
placeholder="Type a town and press Add"
/>
<button type="button" class="btn btn-secondary" on:click={addTown}>Add</button>
</div>
<label class="label">
<span class="label-text-alt">Add any other towns your company services</span>
</label>
{#if formData.townsServiced.length > 0}
<div class="flex flex-wrap gap-2 mt-2">
{#each formData.townsServiced as town}
<div class="badge badge-primary gap-1 p-3">
{town}
<button type="button" class="hover:text-error" aria-label="Remove {town}" on:click={() => removeTown(town)}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="inline-block w-4 h-4 stroke-current"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
</button>
</div>
{/each}
</div>
{/if}
</div>
<div class="form-control">
<label class="label" for="serviceArea">
<span class="label-text">Service Area (Optional)</span>
</label>
<input
id="serviceArea"
type="text"
class="input input-bordered w-full"
bind:value={formData.serviceArea}
maxlength="255"
/>
</div>
</div>
</div>
<div class="bg-base-200 p-4 rounded-lg">
<h3 class="text-lg font-semibold mb-3">Contact Information</h3>
<div class="space-y-4">
<div class="form-control">
<label class="label" for="phone">
<span class="label-text">Phone</span>
</label>
<input
id="phone"
type="tel"
class="input input-bordered w-full {errors.phone ? 'input-error' : ''}"
bind:value={formData.phone}
on:input={handlePhoneInput}
placeholder="123-456-7890"
/>
{#if errors.phone}
<label class="label"><span class="label-text-alt text-error">{errors.phone}</span></label>
{/if}
</div>
<div class="form-control">
<label class="label" for="email">
<span class="label-text">Email (Optional)</span>
</label>
<input
id="email"
type="email"
class="input input-bordered w-full"
bind:value={formData.email}
/>
</div>
<div class="form-control">
<label class="label" for="website">
<span class="label-text">Website (Optional)</span>
</label>
<input
id="website"
type="text"
class="input input-bordered w-full"
bind:value={formData.website}
placeholder="example.com"
/>
</div>
</div>
</div>
<!-- Social Media & Business Links Section -->
<div class="bg-base-200 p-4 rounded-lg">
<h3 class="text-lg font-semibold mb-3">Social Media & Business Links</h3>
<div class="space-y-4">
<!-- Facebook URL -->
<div class="form-control">
<label class="label" for="facebookUrl">
<span class="label-text">Facebook Page URL (Optional)</span>
</label>
<input
id="facebookUrl"
type="text"
class="input input-bordered w-full"
bind:value={formData.facebookUrl}
placeholder="facebook.com/yourpage"
/>
</div>
<!-- Instagram URL -->
<div class="form-control">
<label class="label" for="instagramUrl">
<span class="label-text">Instagram URL (Optional)</span>
</label>
<input
id="instagramUrl"
type="text"
class="input input-bordered w-full"
bind:value={formData.instagramUrl}
placeholder="instagram.com/yourpage"
/>
</div>
<!-- Google Business URL -->
<div class="form-control">
<label class="label" for="googleBusinessUrl">
<span class="label-text">Google Business Page URL (Optional)</span>
</label>
<input
id="googleBusinessUrl"
type="text"
class="input input-bordered w-full"
bind:value={formData.googleBusinessUrl}
placeholder="g.page/r/... or similar"
/>
</div>
</div>
</div>
<div class="form-control mt-6">
<button type="submit" class="btn btn-success w-full" disabled={isSubmitting}>
{#if isSubmitting}
<span class="loading loading-spinner loading-sm"></span>
Saving...
{:else}
Save Changes
{/if}
</button>
</div>
{#if submitMessage}
<div class="alert {submitMessage.includes('successfully') ? 'alert-success' : 'alert-error'}">
<span>{submitMessage}</span>
</div>
{/if}
</form>
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,104 @@
import { PUBLIC_API_URL } from '$env/static/public';
/** @type {import('./$types').RequestHandler} */
export async function GET({ setHeaders, url }) {
// We'll run all of these fetches in parallel to keep things fast.
const baseUrl = 'https://localoilprices.com'; // Use the domain base or fall back to localhost if needed, but sitemaps generally should be explicit
// In SvelteKit SSR (Docker environment), localhost:9552 fails connection refused.
// We should use the host gateway or the docker network alias for the API.
// Given the docker-compose setup, let's detect if we are on server and rewrite.
const apiUrl = typeof window === 'undefined'
? 'http://172.17.0.1:9552' // Docker host IP
: PUBLIC_API_URL;
// First we need all the states + counties
const ne_states = ['ma', 'ct', 'me', 'nh', 'ri', 'vt'];
const countyPromises = ne_states.map(state =>
fetch(`${apiUrl}/state/${state.toUpperCase()}`).then(res => res.json())
);
const [
countiesData,
listingIdsRes,
serviceListingIdsRes
] = await Promise.all([
Promise.all(countyPromises),
fetch(`${apiUrl}/listings/sitemap/all`),
fetch(`${apiUrl}/service-listings/sitemap/all`)
]);
const listings = await listingIdsRes.json();
const serviceListings = await serviceListingIdsRes.json();
let urls = `
<url>
<loc>${baseUrl}/</loc>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
`;
// State & County routes
ne_states.forEach((state, idx) => {
urls += `
<url>
<loc>${baseUrl}/${state}</loc>
<changefreq>daily</changefreq>
<priority>0.9</priority>
</url>
`;
const counties = countiesData[idx] || [];
if (Array.isArray(counties)) {
counties.forEach((county) => {
const safeName = county.name.toLowerCase().replace(/\s+/g, '-');
urls += `
<url>
<loc>${baseUrl}/${state}/${safeName}</loc>
<changefreq>daily</changefreq>
<priority>0.8</priority>
</url>
`;
});
}
});
// Fuel Listings
if (Array.isArray(listings)) {
listings.forEach(listing => {
urls += `
<url>
<loc>${baseUrl}/listing/${listing.id}</loc>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
`;
});
}
// Service Listings
if (Array.isArray(serviceListings)) {
serviceListings.forEach(listing => {
urls += `
<url>
<loc>${baseUrl}/service/${listing.id}</loc>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
`;
});
}
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls}
</urlset>
`;
setHeaders({
'Content-Type': 'application/xml',
'Cache-Control': 'max-age=0, s-maxage=3600'
});
return new Response(sitemap);
}

View File

@@ -1,2 +1,3 @@
User-agent: *
Disallow:
Allow: /
Sitemap: https://localoilprices.com/sitemap.xml

View File

@@ -6,7 +6,7 @@ export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://newenglandoil_api_rust:9552',
target: 'http://localoilprices_api_rust:9552',
changeOrigin: true
}
}