feat: implement SEO improvements, listing profiles, service images, and towns serviced
This commit is contained in:
2
.claude/agent-memory/svelte-frontend-expert/MEMORY.md
Executable file
2
.claude/agent-memory/svelte-frontend-expert/MEMORY.md
Executable file
@@ -0,0 +1,2 @@
|
|||||||
|
# Frontend Agent Memory
|
||||||
|
See main memory at `/mnt/code/tradewar/.claude/agent-memory/svelte-frontend-expert/MEMORY.md`
|
||||||
12
Dockerfile
12
Dockerfile
@@ -1,15 +1,19 @@
|
|||||||
FROM node:latest
|
FROM node:latest
|
||||||
|
|
||||||
ENV PUBLIC_BASE_URL=http://localhost:5170
|
|
||||||
|
|
||||||
RUN mkdir -p /app
|
RUN mkdir -p /app
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
ENV PATH /app/node_modules/.bin:$PATH
|
ENV PATH=/app/node_modules/.bin:$PATH
|
||||||
|
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
||||||
COPY . /app
|
COPY . /app
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "build"]
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
|
||||||
FROM node:20.11.1 AS builder
|
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
|
WORKDIR /app
|
||||||
COPY package*.json .
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|||||||
20
package-lock.json
generated
20
package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
|
"cropperjs": "^1.6.2",
|
||||||
"lucide-svelte": "^0.544.0",
|
"lucide-svelte": "^0.544.0",
|
||||||
"tailwind-merge": "^1.14.0"
|
"tailwind-merge": "^1.14.0"
|
||||||
},
|
},
|
||||||
@@ -1351,6 +1352,11 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.3",
|
"version": "7.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
||||||
@@ -2152,20 +2158,6 @@
|
|||||||
"integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==",
|
"integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/pify": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
|
"cropperjs": "^1.6.2",
|
||||||
"lucide-svelte": "^0.544.0",
|
"lucide-svelte": "^0.544.0",
|
||||||
"tailwind-merge": "^1.14.0"
|
"tailwind-merge": "^1.14.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
/* Write your global styles here, in PostCSS syntax */
|
/* Write your global styles here, in PostCSS syntax */
|
||||||
|
@import 'cropperjs/dist/cropper.min.css';
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|||||||
@@ -17,7 +17,13 @@ import type {
|
|||||||
ServiceCategory,
|
ServiceCategory,
|
||||||
StatsPrice,
|
StatsPrice,
|
||||||
UpdateUserRequest,
|
UpdateUserRequest,
|
||||||
UpdateOilPriceRequest
|
UpdateOilPriceRequest,
|
||||||
|
ServiceListing,
|
||||||
|
CreateServiceListingRequest,
|
||||||
|
UpdateServiceListingRequest,
|
||||||
|
CompanyProfile,
|
||||||
|
Subscription,
|
||||||
|
Banner
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -266,6 +272,12 @@ export const listingsApi = {
|
|||||||
*/
|
*/
|
||||||
async getByCounty(countyId: number): Promise<ApiResponse<Listing[]>> {
|
async getByCounty(countyId: number): Promise<ApiResponse<Listing[]>> {
|
||||||
return request<Listing[]>(`/listings/county/${countyId}`);
|
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
|
* Admin API methods
|
||||||
*/
|
*/
|
||||||
@@ -399,8 +554,14 @@ export const adminApi = {
|
|||||||
export const api = {
|
export const api = {
|
||||||
auth: authApi,
|
auth: authApi,
|
||||||
company: companyApi,
|
company: companyApi,
|
||||||
|
companyProfile: companyProfileApi,
|
||||||
listing: listingApi,
|
listing: listingApi,
|
||||||
listings: listingsApi,
|
listings: listingsApi,
|
||||||
|
serviceListing: serviceListingApi,
|
||||||
|
serviceListings: serviceListingsApi,
|
||||||
|
subscription: subscriptionApi,
|
||||||
|
banner: bannerApi,
|
||||||
|
upload: uploadApi,
|
||||||
oilPrices: oilPricesApi,
|
oilPrices: oilPricesApi,
|
||||||
state: stateApi,
|
state: stateApi,
|
||||||
categories: categoriesApi,
|
categories: categoriesApi,
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
// API Client exports
|
// 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';
|
export * from './types';
|
||||||
|
|||||||
@@ -78,6 +78,12 @@ export interface Listing {
|
|||||||
county_id: number;
|
county_id: number;
|
||||||
town: string | null;
|
town: string | null;
|
||||||
url: 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;
|
user_id: number;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
last_edited?: string;
|
last_edited?: string;
|
||||||
@@ -97,6 +103,10 @@ export interface CreateListingRequest {
|
|||||||
county_id: number;
|
county_id: number;
|
||||||
town?: string | null;
|
town?: string | null;
|
||||||
url?: 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>;
|
export type UpdateListingRequest = Partial<CreateListingRequest>;
|
||||||
@@ -148,5 +158,78 @@ export interface UpdateOilPriceRequest {
|
|||||||
name?: string;
|
name?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
phone?: 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;
|
||||||
}
|
}
|
||||||
|
|||||||
88
src/lib/components/BannerCropModal.svelte
Normal file
88
src/lib/components/BannerCropModal.svelte
Normal 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 • 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}
|
||||||
88
src/lib/components/ImageCropModal.svelte
Normal file
88
src/lib/components/ImageCropModal.svelte
Normal 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 • 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}
|
||||||
@@ -4,7 +4,10 @@
|
|||||||
import type { Writable } from 'svelte/store';
|
import type { Writable } from 'svelte/store';
|
||||||
import '../../app.postcss'; // Import Tailwind CSS
|
import '../../app.postcss'; // Import Tailwind CSS
|
||||||
import { user, darkMode, type User } from '$lib/states';
|
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
|
// Initialize dark mode on mount to ensure data-theme is set
|
||||||
onMount(() => {
|
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
|
// Placeholder for user store - in a real app, this would be managed by an auth library or context
|
||||||
let storedUser: User | null = null;
|
let storedUser: User | null = null;
|
||||||
|
|
||||||
@@ -56,7 +67,7 @@
|
|||||||
<div class="min-h-screen flex flex-col">
|
<div class="min-h-screen flex flex-col">
|
||||||
<header class="navbar bg-primary text-primary-content shadow-lg">
|
<header class="navbar bg-primary text-primary-content shadow-lg">
|
||||||
<div class="flex-1">
|
<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>
|
||||||
<div class="flex-none flex items-center gap-2">
|
<div class="flex-none flex items-center gap-2">
|
||||||
<button type="button" class="btn btn-ghost" on:click={toggleDarkMode}>
|
<button type="button" class="btn btn-ghost" on:click={toggleDarkMode}>
|
||||||
@@ -89,13 +100,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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">
|
<main class="flex-grow container mx-auto p-4">
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="footer footer-center p-4 bg-base-300 text-base-content">
|
<footer class="footer footer-center p-4 bg-base-300 text-base-content">
|
||||||
<div>
|
<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}
|
{#if !$user}
|
||||||
<a href="/login" class="link link-primary">Vendor Login</a>
|
<a href="/login" class="link link-primary">Vendor Login</a>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<!-- src/routes/(app)/+page.svelte -->
|
<!-- src/routes/(app)/+page.svelte -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { newEnglandStates, mapViewBox, user } from '$lib/states';
|
import { newEnglandStates, mapViewBox, user } from '$lib/states';
|
||||||
|
import { PUBLIC_API_URL } from '$env/static/public';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
@@ -15,6 +16,7 @@
|
|||||||
Flame,
|
Flame,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
|
Wrench,
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
|
|
||||||
let hoveredState: string | null = null;
|
let hoveredState: string | null = null;
|
||||||
@@ -88,9 +90,9 @@
|
|||||||
description: 'See heating oil and biofuel prices from local dealers side by side.',
|
description: 'See heating oil and biofuel prices from local dealers side by side.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: MapPin,
|
icon: Wrench,
|
||||||
title: 'Find Local Dealers',
|
title: 'Find Service Companies',
|
||||||
description: 'Discover trusted fuel dealers in your county and neighborhood.',
|
description: 'Locate boiler and furnace repair companies near you — including 24-hour emergency service.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: DollarSign,
|
icon: DollarSign,
|
||||||
@@ -100,14 +102,14 @@
|
|||||||
{
|
{
|
||||||
icon: ThumbsUp,
|
icon: ThumbsUp,
|
||||||
title: 'Always Free',
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Biz Hero - Compare Heating Oil Prices in New England</title>
|
<title>LocalOilPrices - Heating Oil Prices & 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 in your county." />
|
<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>
|
</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">
|
<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>
|
Find the <span class="text-primary">Best Heating Oil Prices</span>
|
||||||
<br class="hidden sm:block" />
|
<br class="hidden sm:block" />
|
||||||
in New England
|
& Service Companies in New England
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<!-- Subheadline -->
|
<!-- Subheadline -->
|
||||||
<p class="text-lg sm:text-xl text-base-content/70 max-w-2xl mx-auto mb-6 px-4 leading-relaxed">
|
<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.
|
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 & furnace service companies — including emergency no-heat response.</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- CTA hint -->
|
<!-- CTA hint -->
|
||||||
@@ -279,10 +281,10 @@
|
|||||||
{#if valueVisible}
|
{#if valueVisible}
|
||||||
<div in:fly={{ y: 20, duration: 400 }}>
|
<div in:fly={{ y: 20, duration: 400 }}>
|
||||||
<h2 class="text-2xl sm:text-3xl font-bold text-center text-base-content mb-2">
|
<h2 class="text-2xl sm:text-3xl font-bold text-center text-base-content mb-2">
|
||||||
Why Use Biz Hero?
|
Why Use LocalOilPrices?
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-base text-base-content/60 text-center mb-8 sm:mb-10">
|
<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>
|
</p>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 max-w-5xl mx-auto">
|
<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}
|
{#if !$user}
|
||||||
<section class="pb-8 text-center">
|
<section class="pb-8 text-center">
|
||||||
<div class="inline-flex flex-col items-center gap-2 px-6 py-4 rounded-xl bg-base-200/40 dark:bg-base-200/20 border border-base-300/40">
|
<div class="inline-flex flex-col items-center gap-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">
|
<p class="text-sm text-base-content/50">
|
||||||
Are you a heating oil vendor?
|
Are you a heating oil or service company?
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href="/login"
|
href="/login"
|
||||||
@@ -328,3 +330,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/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>
|
||||||
|
|||||||
@@ -173,10 +173,10 @@
|
|||||||
<!-- SEO -->
|
<!-- SEO -->
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
{#if stateData}
|
{#if stateData}
|
||||||
<title>Heating Oil Prices in {stateData.name} | Biz Hero</title>
|
<title>Heating Oil Prices & Service Companies in {stateData.name} | LocalOilPrices</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." />
|
<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}
|
{:else}
|
||||||
<title>State Not Found | Biz Hero</title>
|
<title>State Not Found | LocalOilPrices</title>
|
||||||
{/if}
|
{/if}
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
@@ -222,7 +222,9 @@
|
|||||||
|
|
||||||
<!-- Subtitle -->
|
<!-- Subtitle -->
|
||||||
<p class="text-lg sm:text-xl text-base-content/70 max-w-xl mx-auto leading-relaxed px-4">
|
<p class="text-lg sm:text-xl text-base-content/70 max-w-xl mx-auto leading-relaxed px-4">
|
||||||
Explore heating oil prices by county
|
Explore heating oil prices & 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}
|
{#if statePrice}
|
||||||
<br/>
|
<br/>
|
||||||
<span class="inline-block mt-2 px-3 py-1 bg-base-200 rounded-full text-base font-semibold text-primary">
|
<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}
|
Counties in {stateData.name}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-base text-base-content/60 text-center mb-6 sm:mb-8">
|
<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>
|
</p>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
@@ -365,7 +367,7 @@
|
|||||||
<span class="block text-sm sm:text-base font-semibold text-base-content truncate leading-tight">
|
<span class="block text-sm sm:text-base font-semibold text-base-content truncate leading-tight">
|
||||||
{county.name}
|
{county.name}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { newEnglandStates } from '../../../../lib/states';
|
import { newEnglandStates } from '../../../../lib/states';
|
||||||
|
import { PUBLIC_API_URL } from '$env/static/public';
|
||||||
import { api } from '$lib/api';
|
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 { fade, fly } from 'svelte/transition';
|
||||||
import {
|
import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
let countyData: County | null = null;
|
let countyData: County | null = null;
|
||||||
let listings: Listing[] = [];
|
let listings: Listing[] = [];
|
||||||
let oilPrices: OilPrice[] = [];
|
let oilPrices: OilPrice[] = [];
|
||||||
|
let serviceListings: ServiceListing[] = [];
|
||||||
let loading = true;
|
let loading = true;
|
||||||
let listingsLoading = false;
|
let listingsLoading = false;
|
||||||
let error: string | null = null;
|
let error: string | null = null;
|
||||||
@@ -33,6 +35,9 @@
|
|||||||
let sortColumn = 'price_per_gallon';
|
let sortColumn = 'price_per_gallon';
|
||||||
let sortDirection = 'asc';
|
let sortDirection = 'asc';
|
||||||
|
|
||||||
|
// Active tab: 'fuel' or 'service'
|
||||||
|
let activeTab: 'fuel' | 'service' = 'fuel';
|
||||||
|
|
||||||
// Market Prices sorting
|
// Market Prices sorting
|
||||||
let marketSortColumn = 'date';
|
let marketSortColumn = 'date';
|
||||||
let marketSortDirection = 'desc';
|
let marketSortDirection = 'desc';
|
||||||
@@ -67,9 +72,10 @@
|
|||||||
listingsLoading = true;
|
listingsLoading = true;
|
||||||
listingsError = null;
|
listingsError = null;
|
||||||
|
|
||||||
const [listingsResult, oilPricesResult] = await Promise.all([
|
const [listingsResult, oilPricesResult, serviceResult] = await Promise.all([
|
||||||
api.listings.getByCounty(countyData.id),
|
api.listings.getByCounty(countyData.id),
|
||||||
api.oilPrices.getByCounty(countyData.id)
|
api.oilPrices.getByCounty(countyData.id),
|
||||||
|
api.serviceListings.getByCounty(countyData.id)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (listingsResult.error) {
|
if (listingsResult.error) {
|
||||||
@@ -83,6 +89,9 @@
|
|||||||
oilPrices = (oilPricesResult.data || []).filter(p => p.price !== 0);
|
oilPrices = (oilPricesResult.data || []).filter(p => p.price !== 0);
|
||||||
sortMarketPrices(); // Sort immediately after fetching
|
sortMarketPrices(); // Sort immediately after fetching
|
||||||
|
|
||||||
|
// Service listings failure is non-critical
|
||||||
|
serviceListings = serviceResult.data || [];
|
||||||
|
|
||||||
listingsLoading = false;
|
listingsLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,10 +235,10 @@
|
|||||||
<!-- SEO -->
|
<!-- SEO -->
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
{#if countyData}
|
{#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." />
|
<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}
|
{:else}
|
||||||
<title>County Not Found | Biz Hero</title>
|
<title>County Not Found | LocalOilPrices</title>
|
||||||
{/if}
|
{/if}
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
@@ -316,15 +325,46 @@
|
|||||||
|
|
||||||
<!-- Subtitle -->
|
<!-- Subtitle -->
|
||||||
<p class="text-base text-base-content/50 max-w-xl mx-auto leading-relaxed px-4">
|
<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>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/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 -->
|
<!-- LISTINGS CONTENT -->
|
||||||
<!-- ============================================================ -->
|
<!-- ============================================================ -->
|
||||||
|
{#if activeTab === 'fuel'}
|
||||||
|
|
||||||
{#if listingsLoading}
|
{#if listingsLoading}
|
||||||
<!-- Skeleton loading for listings -->
|
<!-- Skeleton loading for listings -->
|
||||||
<div class="px-2 mt-4">
|
<div class="px-2 mt-4">
|
||||||
@@ -520,16 +560,25 @@
|
|||||||
>
|
>
|
||||||
<!-- Company name -->
|
<!-- Company name -->
|
||||||
<td class="px-4 py-4">
|
<td class="px-4 py-4">
|
||||||
<div class="flex flex-col">
|
<div class="flex gap-3 items-center">
|
||||||
<span class="text-base font-semibold text-base-content">
|
<div class="w-16 h-16 rounded-full overflow-hidden bg-primary flex items-center justify-center flex-shrink-0">
|
||||||
{listing.company_name}
|
{#if listing.logo_url}
|
||||||
</span>
|
<img src="{PUBLIC_API_URL}{listing.logo_url}" alt="" class="w-full h-full object-cover" />
|
||||||
{#if listing.url}
|
{:else}
|
||||||
<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>
|
<span class="text-xl text-primary-content font-bold">{listing.company_name.charAt(0).toUpperCase()}</span>
|
||||||
<Globe size={10} />
|
{/if}
|
||||||
Visit Website
|
</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>
|
</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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
@@ -588,9 +637,13 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<span class="text-sm text-base-content/40"></span>
|
<span class="text-sm text-base-content/40"></span>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="text-xs text-base-content/40 flex items-center gap-1">
|
<div class="text-xs flex items-center gap-1">
|
||||||
<Globe size={11} />
|
<Globe size={11} class="text-base-content/40" />
|
||||||
{formatOnlineOrdering(listing.online_ordering)}
|
{#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>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -646,12 +699,27 @@
|
|||||||
<div class="lg:hidden space-y-3">
|
<div class="lg:hidden space-y-3">
|
||||||
{#each listings.filter(l => l.phone) as listing, i}
|
{#each listings.filter(l => l.phone) as listing, i}
|
||||||
<div class="listing-card stagger-{(i % 6) + 1} opacity-0 animate-fade-in-up">
|
<div class="listing-card stagger-{(i % 6) + 1} opacity-0 animate-fade-in-up">
|
||||||
|
{#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 -->
|
<!-- Top row: Company + Price -->
|
||||||
<div class="flex items-start justify-between gap-3 mb-3">
|
<div class="flex items-start justify-between gap-3 mb-3">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="flex gap-3 min-w-0 flex-1">
|
||||||
<h3 class="text-lg font-bold text-base-content leading-tight truncate">
|
<div class="w-16 h-16 rounded-full overflow-hidden bg-primary flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
{listing.company_name}
|
{#if listing.logo_url}
|
||||||
</h3>
|
<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}
|
{#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>
|
<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} />
|
<Globe size={11} />
|
||||||
@@ -664,6 +732,7 @@
|
|||||||
{listing.town}
|
{listing.town}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right flex-shrink-0">
|
<div class="text-right flex-shrink-0">
|
||||||
<div class="price-hero !text-2xl">
|
<div class="price-hero !text-2xl">
|
||||||
@@ -707,7 +776,11 @@
|
|||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<Globe size={14} class="text-base-content/40 flex-shrink-0" />
|
<Globe size={14} class="text-base-content/40 flex-shrink-0" />
|
||||||
<span class="text-base-content/60">Order:</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Last updated -->
|
<!-- Last updated -->
|
||||||
@@ -930,6 +1003,210 @@
|
|||||||
|
|
||||||
{/if}
|
{/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 & 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) -->
|
<!-- BACK NAVIGATION (subtle, bottom) -->
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
UpdateCompanyRequest,
|
UpdateCompanyRequest,
|
||||||
UpdateListingRequest,
|
UpdateListingRequest,
|
||||||
UpdateOilPriceRequest,
|
UpdateOilPriceRequest,
|
||||||
|
Banner,
|
||||||
} from "$lib/api/types";
|
} from "$lib/api/types";
|
||||||
|
|
||||||
let activeTab = "listings";
|
let activeTab = "listings";
|
||||||
@@ -20,6 +21,12 @@
|
|||||||
let loading = false;
|
let loading = false;
|
||||||
let error: string | null = null;
|
let error: string | null = null;
|
||||||
|
|
||||||
|
// Banner management state
|
||||||
|
let currentBanner: Banner | null = null;
|
||||||
|
let newBannerMessage = '';
|
||||||
|
let bannerLoading = false;
|
||||||
|
let bannerSuccess = '';
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: "listings", label: "Listings" },
|
{ id: "listings", label: "Listings" },
|
||||||
{ id: "companies", label: "Companies" },
|
{ id: "companies", label: "Companies" },
|
||||||
@@ -105,8 +112,45 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await loadData();
|
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() {
|
async function loadData() {
|
||||||
loading = true;
|
loading = true;
|
||||||
error = null;
|
error = null;
|
||||||
@@ -215,6 +259,56 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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">
|
<div class="tabs tabs-boxed mb-4">
|
||||||
{#each tabs as tab}
|
{#each tabs as tab}
|
||||||
<button
|
<button
|
||||||
|
|||||||
364
src/routes/(app)/company/[id]/+page.svelte
Normal file
364
src/routes/(app)/company/[id]/+page.svelte
Normal 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}
|
||||||
362
src/routes/(app)/company/user/[userId]/+page.svelte
Normal file
362
src/routes/(app)/company/user/[userId]/+page.svelte
Normal 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}
|
||||||
347
src/routes/(app)/listing/[id]/+page.svelte
Normal file
347
src/routes/(app)/listing/[id]/+page.svelte
Normal 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}
|
||||||
324
src/routes/(app)/service/[id]/+page.svelte
Normal file
324
src/routes/(app)/service/[id]/+page.svelte
Normal 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}
|
||||||
232
src/routes/(app)/vendor/+page.svelte
vendored
232
src/routes/(app)/vendor/+page.svelte
vendored
@@ -1,15 +1,21 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { api } from '$lib/api';
|
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 listings: Listing[] = [];
|
||||||
|
let serviceListings: ServiceListing[] = [];
|
||||||
let company: Company | null = null;
|
let company: Company | null = null;
|
||||||
let isLoading = true;
|
let isLoading = true;
|
||||||
|
let serviceLoading = true;
|
||||||
let companyLoading = true;
|
let companyLoading = true;
|
||||||
let error = '';
|
let error = '';
|
||||||
|
let serviceError = '';
|
||||||
let companyError = '';
|
let companyError = '';
|
||||||
let successMessage = '';
|
let successMessage = '';
|
||||||
|
let subscription: Subscription | null = null;
|
||||||
|
let subscriptionLoading = true;
|
||||||
|
|
||||||
// Inline editing state
|
// Inline editing state
|
||||||
let editingId: number | null = null;
|
let editingId: number | null = null;
|
||||||
@@ -34,7 +40,7 @@
|
|||||||
|
|
||||||
async function fetchListings() {
|
async function fetchListings() {
|
||||||
const result = await api.listing.getAll();
|
const result = await api.listing.getAll();
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
if (result.error !== 'Session expired. Please log in again.') {
|
if (result.error !== 'Session expired. Please log in again.') {
|
||||||
error = 'Failed to load listings';
|
error = 'Failed to load listings';
|
||||||
@@ -45,6 +51,28 @@
|
|||||||
isLoading = false;
|
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) {
|
async function deleteListing(id: number) {
|
||||||
if (!confirm('Are you sure you want to delete this listing?')) return;
|
if (!confirm('Are you sure you want to delete this listing?')) return;
|
||||||
|
|
||||||
@@ -152,6 +180,25 @@
|
|||||||
return phone;
|
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(() => {
|
onMount(() => {
|
||||||
if (!api.auth.isAuthenticated()) {
|
if (!api.auth.isAuthenticated()) {
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
@@ -159,11 +206,21 @@
|
|||||||
}
|
}
|
||||||
fetchCompany();
|
fetchCompany();
|
||||||
fetchListings();
|
fetchListings();
|
||||||
|
fetchServiceListings();
|
||||||
|
fetchSubscription();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="container mx-auto p-4">
|
<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 -->
|
<!-- Success Banner -->
|
||||||
{#if successMessage}
|
{#if successMessage}
|
||||||
@@ -256,12 +313,50 @@
|
|||||||
<!-- Navigation Links -->
|
<!-- Navigation Links -->
|
||||||
<div class="flex flex-wrap gap-4 mb-8">
|
<div class="flex flex-wrap gap-4 mb-8">
|
||||||
<a href="/vendor/profile" class="btn btn-outline">Company Profile</a>
|
<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>
|
</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 -->
|
<!-- Listings Table -->
|
||||||
<div class="mt-8">
|
<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}
|
{#if isLoading}
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
@@ -273,8 +368,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if listings.length === 0}
|
{:else if listings.length === 0}
|
||||||
<div class="text-center py-8">
|
<div class="text-center py-8">
|
||||||
<p class="text-lg text-gray-600">No listings found.. create one :)</p>
|
<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">Create Listing</a>
|
<a href="/vendor/listing" class="btn btn-primary mt-4">Add Oil Company Listing</a>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
@@ -296,7 +391,18 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{#each listings.filter(l => l.phone) as listing}
|
{#each listings.filter(l => l.phone) as listing}
|
||||||
<tr>
|
<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>
|
<td>
|
||||||
{#if listing.town}
|
{#if listing.town}
|
||||||
{listing.town} (ID: {listing.county_id})
|
{listing.town} (ID: {listing.county_id})
|
||||||
@@ -458,4 +564,114 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|||||||
57
src/routes/(app)/vendor/listing/+page.svelte
vendored
57
src/routes/(app)/vendor/listing/+page.svelte
vendored
@@ -16,9 +16,23 @@
|
|||||||
state: '',
|
state: '',
|
||||||
countyId: 0,
|
countyId: 0,
|
||||||
town: '',
|
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
|
// Active status
|
||||||
let isActive = true;
|
let isActive = true;
|
||||||
|
|
||||||
@@ -146,7 +160,8 @@
|
|||||||
county_id: formData.countyId,
|
county_id: formData.countyId,
|
||||||
|
|
||||||
town: formData.town.trim() || null,
|
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) {
|
if (result.error) {
|
||||||
@@ -249,10 +264,10 @@
|
|||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="url"
|
id="url"
|
||||||
type="url"
|
type="text"
|
||||||
class="input input-bordered w-full"
|
class="input input-bordered w-full"
|
||||||
bind:value={formData.url}
|
bind:value={formData.url}
|
||||||
placeholder="https://example.com"
|
placeholder="example.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -444,6 +459,40 @@
|
|||||||
<span class="label-text-alt">Specify the town within the selected county</span>
|
<span class="label-text-alt">Specify the town within the selected county</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
351
src/routes/(app)/vendor/listing/[id]/+page.svelte
vendored
351
src/routes/(app)/vendor/listing/[id]/+page.svelte
vendored
@@ -4,6 +4,9 @@
|
|||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { newEnglandStates } from '$lib/states';
|
import { newEnglandStates } from '$lib/states';
|
||||||
import { api } from '$lib/api';
|
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';
|
import type { County, Listing } from '$lib/api';
|
||||||
|
|
||||||
// Get listing ID from URL params
|
// Get listing ID from URL params
|
||||||
@@ -22,9 +25,26 @@
|
|||||||
state: '',
|
state: '',
|
||||||
countyId: 0,
|
countyId: 0,
|
||||||
town: '',
|
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
|
// Active status
|
||||||
let isActive = true;
|
let isActive = true;
|
||||||
|
|
||||||
@@ -52,6 +72,20 @@
|
|||||||
let submitMessage = '';
|
let submitMessage = '';
|
||||||
let isLoading = true;
|
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
|
// Location selector state
|
||||||
let counties: County[] = [];
|
let counties: County[] = [];
|
||||||
let isLoadingCounties = false;
|
let isLoadingCounties = false;
|
||||||
@@ -80,6 +114,12 @@
|
|||||||
formData.countyId = listing.county_id;
|
formData.countyId = listing.county_id;
|
||||||
formData.town = listing.town || '';
|
formData.town = listing.town || '';
|
||||||
formData.url = listing.url || '';
|
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
|
// Load the state for this county
|
||||||
await loadStateForCounty(listing.county_id);
|
await loadStateForCounty(listing.county_id);
|
||||||
@@ -203,7 +243,11 @@
|
|||||||
online_ordering: onlineOrdering,
|
online_ordering: onlineOrdering,
|
||||||
county_id: formData.countyId,
|
county_id: formData.countyId,
|
||||||
town: formData.town.trim() || null,
|
town: formData.town.trim() || null,
|
||||||
url: formData.url.trim() || null
|
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) {
|
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(() => {
|
onMount(() => {
|
||||||
loadListing();
|
loadListing();
|
||||||
});
|
});
|
||||||
</script>
|
</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) -->
|
<!-- Breadcrumbs (full width, left aligned) -->
|
||||||
<nav class="breadcrumbs text-sm mb-4 px-4">
|
<nav class="breadcrumbs text-sm mb-4 px-4">
|
||||||
<ul>
|
<ul>
|
||||||
@@ -270,6 +423,114 @@
|
|||||||
<a href="/vendor" class="text-blue-500 hover:underline">Back to Vendor Dashboard</a>
|
<a href="/vendor" class="text-blue-500 hover:underline">Back to Vendor Dashboard</a>
|
||||||
</div>
|
</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 • Max 5MB • Displayed at 170×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 • Max 5MB • Displayed at top of mobile listings (3:1 wide format)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Edit Listing Form -->
|
<!-- Edit Listing Form -->
|
||||||
<div class="card bg-base-100 shadow-xl mb-8">
|
<div class="card bg-base-100 shadow-xl mb-8">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@@ -315,10 +576,10 @@
|
|||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="url"
|
id="url"
|
||||||
type="url"
|
type="text"
|
||||||
class="input input-bordered w-full"
|
class="input input-bordered w-full"
|
||||||
bind:value={formData.url}
|
bind:value={formData.url}
|
||||||
placeholder="https://example.com"
|
placeholder="example.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -510,6 +771,40 @@
|
|||||||
<span class="label-text-alt">Specify the town within the selected county</span>
|
<span class="label-text-alt">Specify the town within the selected county</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -560,6 +855,54 @@
|
|||||||
</div>
|
</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>
|
||||||
|
|
||||||
<!-- Submit Button -->
|
<!-- Submit Button -->
|
||||||
<div class="form-control mt-6">
|
<div class="form-control mt-6">
|
||||||
<button
|
<button
|
||||||
|
|||||||
448
src/routes/(app)/vendor/service/+page.svelte
vendored
Normal file
448
src/routes/(app)/vendor/service/+page.svelte
vendored
Normal 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 & 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>
|
||||||
780
src/routes/(app)/vendor/service/[id]/+page.svelte
vendored
Normal file
780
src/routes/(app)/vendor/service/[id]/+page.svelte
vendored
Normal 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 • Max 5MB • Displayed at 170×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 • Max 5MB • 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 & 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>
|
||||||
104
src/routes/sitemap.xml/+server.ts
Normal file
104
src/routes/sitemap.xml/+server.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
User-agent: *
|
User-agent: *
|
||||||
Disallow:
|
Allow: /
|
||||||
|
Sitemap: https://localoilprices.com/sitemap.xml
|
||||||
@@ -6,7 +6,7 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://newenglandoil_api_rust:9552',
|
target: 'http://localoilprices_api_rust:9552',
|
||||||
changeOrigin: true
|
changeOrigin: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user