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
|
||||
|
||||
ENV PUBLIC_BASE_URL=http://localhost:5170
|
||||
|
||||
RUN mkdir -p /app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
ENV PATH /app/node_modules/.bin:$PATH
|
||||
ENV PATH=/app/node_modules/.bin:$PATH
|
||||
|
||||
RUN npm install
|
||||
|
||||
COPY . /app
|
||||
COPY . /app
|
||||
|
||||
RUN npm run build
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "build"]
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
|
||||
FROM node:20.11.1 AS builder
|
||||
|
||||
ENV PUBLIC_BASE_URL=https://api.auburnoil.com
|
||||
ARG PUBLIC_API_URL=https://api.localoilprices.com
|
||||
ENV PUBLIC_API_URL=$PUBLIC_API_URL
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json .
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
20
package-lock.json
generated
20
package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"cropperjs": "^1.6.2",
|
||||
"lucide-svelte": "^0.544.0",
|
||||
"tailwind-merge": "^1.14.0"
|
||||
},
|
||||
@@ -1351,6 +1352,11 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cropperjs": {
|
||||
"version": "1.6.2",
|
||||
"resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.6.2.tgz",
|
||||
"integrity": "sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA=="
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
||||
@@ -2152,20 +2158,6 @@
|
||||
"integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
|
||||
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pify": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"cropperjs": "^1.6.2",
|
||||
"lucide-svelte": "^0.544.0",
|
||||
"tailwind-merge": "^1.14.0"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
/* Write your global styles here, in PostCSS syntax */
|
||||
@import 'cropperjs/dist/cropper.min.css';
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@@ -17,7 +17,13 @@ import type {
|
||||
ServiceCategory,
|
||||
StatsPrice,
|
||||
UpdateUserRequest,
|
||||
UpdateOilPriceRequest
|
||||
UpdateOilPriceRequest,
|
||||
ServiceListing,
|
||||
CreateServiceListingRequest,
|
||||
UpdateServiceListingRequest,
|
||||
CompanyProfile,
|
||||
Subscription,
|
||||
Banner
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
@@ -266,6 +272,12 @@ export const listingsApi = {
|
||||
*/
|
||||
async getByCounty(countyId: number): Promise<ApiResponse<Listing[]>> {
|
||||
return request<Listing[]>(`/listings/county/${countyId}`);
|
||||
},
|
||||
/**
|
||||
* Get a single listing by ID (public)
|
||||
*/
|
||||
async getById(id: number): Promise<ApiResponse<Listing>> {
|
||||
return request<Listing>(`/listings/${id}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -324,6 +336,149 @@ export const categoriesApi = {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Image upload API methods
|
||||
*/
|
||||
export const uploadApi = {
|
||||
/** Upload a logo for a fuel listing. Returns { logo_url } on success. */
|
||||
async uploadListingImage(listingId: number, file: File): Promise<ApiResponse<{ logo_url: string }>> {
|
||||
const body = new FormData();
|
||||
body.append('image', file);
|
||||
return request<{ logo_url: string }>(`/upload/listing/${listingId}/image`, {
|
||||
method: 'POST',
|
||||
body: body as unknown as BodyInit,
|
||||
}, true);
|
||||
},
|
||||
|
||||
async deleteListingImage(listingId: number): Promise<ApiResponse<null>> {
|
||||
return request<null>(`/upload/listing/${listingId}/image`, { method: 'DELETE' }, true);
|
||||
},
|
||||
|
||||
async uploadServiceListingImage(listingId: number, file: File): Promise<ApiResponse<{ logo_url: string }>> {
|
||||
const body = new FormData();
|
||||
body.append('image', file);
|
||||
return request<{ logo_url: string }>(`/upload/service-listing/${listingId}/image`, {
|
||||
method: 'POST',
|
||||
body: body as unknown as BodyInit,
|
||||
}, true);
|
||||
},
|
||||
|
||||
async deleteServiceListingImage(listingId: number): Promise<ApiResponse<null>> {
|
||||
return request<null>(`/upload/service-listing/${listingId}/image`, { method: 'DELETE' }, true);
|
||||
},
|
||||
|
||||
async uploadListingBanner(listingId: number, file: File): Promise<ApiResponse<{ banner_url: string }>> {
|
||||
const body = new FormData();
|
||||
body.append('image', file);
|
||||
return request<{ banner_url: string }>(`/upload/listing/${listingId}/banner`, {
|
||||
method: 'POST',
|
||||
body: body as unknown as BodyInit,
|
||||
}, true);
|
||||
},
|
||||
|
||||
async deleteListingBanner(listingId: number): Promise<ApiResponse<null>> {
|
||||
return request<null>(`/upload/listing/${listingId}/banner`, { method: 'DELETE' }, true);
|
||||
},
|
||||
|
||||
async uploadServiceListingBanner(listingId: number, file: File): Promise<ApiResponse<{ banner_url: string }>> {
|
||||
const body = new FormData();
|
||||
body.append('image', file);
|
||||
return request<{ banner_url: string }>(`/upload/service-listing/${listingId}/banner`, {
|
||||
method: 'POST',
|
||||
body: body as unknown as BodyInit,
|
||||
}, true);
|
||||
},
|
||||
|
||||
async deleteServiceListingBanner(listingId: number): Promise<ApiResponse<null>> {
|
||||
return request<null>(`/upload/service-listing/${listingId}/banner`, { method: 'DELETE' }, true);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Service Listing API methods (for authenticated user's service listings)
|
||||
*/
|
||||
export const serviceListingApi = {
|
||||
async getAll(): Promise<ApiResponse<ServiceListing[]>> {
|
||||
return request<ServiceListing[]>('/service-listing', {}, true);
|
||||
},
|
||||
async getById(id: number): Promise<ApiResponse<ServiceListing>> {
|
||||
return request<ServiceListing>(`/service-listing/${id}`, {}, true);
|
||||
},
|
||||
async create(data: CreateServiceListingRequest): Promise<ApiResponse<ServiceListing>> {
|
||||
return request<ServiceListing>('/service-listing', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}, true);
|
||||
},
|
||||
async update(id: number, data: UpdateServiceListingRequest): Promise<ApiResponse<ServiceListing>> {
|
||||
return request<ServiceListing>(`/service-listing/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}, true);
|
||||
},
|
||||
async delete(id: number): Promise<ApiResponse<null>> {
|
||||
return request<null>(`/service-listing/${id}`, {
|
||||
method: 'DELETE',
|
||||
}, true);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Public service listings API (by county)
|
||||
*/
|
||||
export const serviceListingsApi = {
|
||||
async getByCounty(countyId: number): Promise<ApiResponse<ServiceListing[]>> {
|
||||
return request<ServiceListing[]>(`/service-listings/county/${countyId}`);
|
||||
},
|
||||
async getById(id: number): Promise<ApiResponse<ServiceListing>> {
|
||||
return request<ServiceListing>(`/service-listings/${id}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Public company profile API
|
||||
*/
|
||||
export const companyProfileApi = {
|
||||
async getById(companyId: number): Promise<ApiResponse<CompanyProfile>> {
|
||||
return request<CompanyProfile>(`/company/${companyId}`);
|
||||
},
|
||||
async getByUserId(userId: number): Promise<ApiResponse<CompanyProfile>> {
|
||||
return request<CompanyProfile>(`/company/user/${userId}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscription API (authenticated)
|
||||
*/
|
||||
export const subscriptionApi = {
|
||||
async get(): Promise<ApiResponse<Subscription>> {
|
||||
return request<Subscription>('/subscription', {}, true);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Banner API methods
|
||||
*/
|
||||
export const bannerApi = {
|
||||
/** Public: get active banner */
|
||||
async getActive(): Promise<ApiResponse<Banner | null>> {
|
||||
return request<Banner | null>('/banner');
|
||||
},
|
||||
/** Admin: create/update banner */
|
||||
async create(message: string): Promise<ApiResponse<Banner>> {
|
||||
return request<Banner>('/admin/banner', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ message }),
|
||||
}, true);
|
||||
},
|
||||
/** Admin: delete (deactivate) a banner */
|
||||
async delete(id: number): Promise<ApiResponse<null>> {
|
||||
return request<null>(`/admin/banner/${id}`, {
|
||||
method: 'DELETE',
|
||||
}, true);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Admin API methods
|
||||
*/
|
||||
@@ -399,8 +554,14 @@ export const adminApi = {
|
||||
export const api = {
|
||||
auth: authApi,
|
||||
company: companyApi,
|
||||
companyProfile: companyProfileApi,
|
||||
listing: listingApi,
|
||||
listings: listingsApi,
|
||||
serviceListing: serviceListingApi,
|
||||
serviceListings: serviceListingsApi,
|
||||
subscription: subscriptionApi,
|
||||
banner: bannerApi,
|
||||
upload: uploadApi,
|
||||
oilPrices: oilPricesApi,
|
||||
state: stateApi,
|
||||
categories: categoriesApi,
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// API Client exports
|
||||
export { api, authApi, companyApi, listingApi, listingsApi, oilPricesApi, stateApi, categoriesApi } from './client';
|
||||
export { api, authApi, companyApi, companyProfileApi, listingApi, listingsApi, serviceListingApi, serviceListingsApi, subscriptionApi, bannerApi, uploadApi, oilPricesApi, stateApi, categoriesApi } from './client';
|
||||
export * from './types';
|
||||
|
||||
@@ -78,6 +78,12 @@ export interface Listing {
|
||||
county_id: number;
|
||||
town: string | null;
|
||||
url: string | null;
|
||||
logo_url: string | null;
|
||||
banner_url: string | null;
|
||||
facebook_url: string | null;
|
||||
instagram_url: string | null;
|
||||
google_business_url: string | null;
|
||||
towns_serviced?: string[] | null;
|
||||
user_id: number;
|
||||
created_at?: string;
|
||||
last_edited?: string;
|
||||
@@ -97,6 +103,10 @@ export interface CreateListingRequest {
|
||||
county_id: number;
|
||||
town?: string | null;
|
||||
url?: string | null;
|
||||
facebook_url?: string | null;
|
||||
instagram_url?: string | null;
|
||||
google_business_url?: string | null;
|
||||
towns_serviced?: string[] | null;
|
||||
}
|
||||
|
||||
export type UpdateListingRequest = Partial<CreateListingRequest>;
|
||||
@@ -148,5 +158,78 @@ export interface UpdateOilPriceRequest {
|
||||
name?: string;
|
||||
url?: string;
|
||||
phone?: string;
|
||||
|
||||
}
|
||||
|
||||
// Service Listing Types (boiler/HVAC service companies)
|
||||
export interface ServiceListing {
|
||||
id: number;
|
||||
company_name: string;
|
||||
is_active: boolean;
|
||||
twenty_four_hour: boolean;
|
||||
emergency_service: boolean;
|
||||
town: string | null;
|
||||
county_id: number;
|
||||
phone: string | null;
|
||||
website: string | null;
|
||||
email: string | null;
|
||||
description: string | null;
|
||||
licensed_insured: boolean;
|
||||
service_area: string | null;
|
||||
years_experience: number | null;
|
||||
user_id: number;
|
||||
last_edited?: string;
|
||||
logo_url: string | null;
|
||||
banner_url: string | null;
|
||||
facebook_url: string | null;
|
||||
instagram_url: string | null;
|
||||
google_business_url: string | null;
|
||||
towns_serviced?: string[] | null;
|
||||
}
|
||||
|
||||
export interface CreateServiceListingRequest {
|
||||
company_name: string;
|
||||
is_active?: boolean;
|
||||
twenty_four_hour?: boolean;
|
||||
emergency_service?: boolean;
|
||||
town?: string | null;
|
||||
county_id: number;
|
||||
phone?: string | null;
|
||||
website?: string | null;
|
||||
email?: string | null;
|
||||
description?: string | null;
|
||||
licensed_insured?: boolean;
|
||||
service_area?: string | null;
|
||||
years_experience?: number | null;
|
||||
facebook_url?: string | null;
|
||||
instagram_url?: string | null;
|
||||
google_business_url?: string | null;
|
||||
towns_serviced?: string[] | null;
|
||||
}
|
||||
|
||||
export type UpdateServiceListingRequest = Partial<CreateServiceListingRequest>;
|
||||
|
||||
// Company Profile (public-facing)
|
||||
export interface CompanyProfile {
|
||||
company: Company;
|
||||
fuel_listings: Listing[];
|
||||
service_listings: ServiceListing[];
|
||||
}
|
||||
|
||||
// Subscription Types
|
||||
export interface Subscription {
|
||||
id: number;
|
||||
company_id: number;
|
||||
trial_start: string;
|
||||
trial_end: string;
|
||||
status: string;
|
||||
plan: string | null;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
// Banner Types (admin-managed site-wide banners)
|
||||
export interface Banner {
|
||||
id: number;
|
||||
message: string;
|
||||
is_active: boolean;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
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 '../../app.postcss'; // Import Tailwind CSS
|
||||
import { user, darkMode, type User } from '$lib/states';
|
||||
import { authApi } from '$lib/api';
|
||||
import { authApi, bannerApi } from '$lib/api';
|
||||
import type { Banner } from '$lib/api';
|
||||
|
||||
let activeBanner: Banner | null = null;
|
||||
|
||||
// Initialize dark mode on mount to ensure data-theme is set
|
||||
onMount(() => {
|
||||
@@ -16,6 +19,14 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch active banner
|
||||
onMount(async () => {
|
||||
const result = await bannerApi.getActive();
|
||||
if (!result.error && result.data) {
|
||||
activeBanner = result.data;
|
||||
}
|
||||
});
|
||||
|
||||
// Placeholder for user store - in a real app, this would be managed by an auth library or context
|
||||
let storedUser: User | null = null;
|
||||
|
||||
@@ -56,7 +67,7 @@
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<header class="navbar bg-primary text-primary-content shadow-lg">
|
||||
<div class="flex-1">
|
||||
<a href="/" class="btn btn-ghost normal-case text-xl">Biz Hero</a>
|
||||
<a href="/" class="btn btn-ghost normal-case text-xl">LocalOilPrices</a>
|
||||
</div>
|
||||
<div class="flex-none flex items-center gap-2">
|
||||
<button type="button" class="btn btn-ghost" on:click={toggleDarkMode}>
|
||||
@@ -89,13 +100,24 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Admin Banner -->
|
||||
{#if activeBanner}
|
||||
<div class="flex justify-center px-4 pt-3">
|
||||
<div class="flex items-center gap-3 px-4 py-3 rounded-xl shadow-lg max-w-2xl w-full bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700/40 text-amber-700 dark:text-amber-300">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-5 w-5" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||
<span class="text-sm flex-1">{activeBanner.message}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<main class="flex-grow container mx-auto p-4">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<footer class="footer footer-center p-4 bg-base-300 text-base-content">
|
||||
<div>
|
||||
<p>Copyright © {new Date().getFullYear()} - All right reserved</p>
|
||||
<p>Copyright © {new Date().getFullYear()} Rocket Services LLC - All rights reserved</p>
|
||||
<p class="text-sm text-primary font-semibold">🔥 1 Year Free Trial Per Account!</p>
|
||||
{#if !$user}
|
||||
<a href="/login" class="link link-primary">Vendor Login</a>
|
||||
{/if}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<!-- src/routes/(app)/+page.svelte -->
|
||||
<script lang="ts">
|
||||
import { newEnglandStates, mapViewBox, user } from '$lib/states';
|
||||
import { PUBLIC_API_URL } from '$env/static/public';
|
||||
import { goto } from '$app/navigation';
|
||||
import { browser } from '$app/environment';
|
||||
import { api } from '$lib/api';
|
||||
@@ -15,6 +16,7 @@
|
||||
Flame,
|
||||
ChevronRight,
|
||||
ArrowDown,
|
||||
Wrench,
|
||||
} from 'lucide-svelte';
|
||||
|
||||
let hoveredState: string | null = null;
|
||||
@@ -88,9 +90,9 @@
|
||||
description: 'See heating oil and biofuel prices from local dealers side by side.',
|
||||
},
|
||||
{
|
||||
icon: MapPin,
|
||||
title: 'Find Local Dealers',
|
||||
description: 'Discover trusted fuel dealers in your county and neighborhood.',
|
||||
icon: Wrench,
|
||||
title: 'Find Service Companies',
|
||||
description: 'Locate boiler and furnace repair companies near you — including 24-hour emergency service.',
|
||||
},
|
||||
{
|
||||
icon: DollarSign,
|
||||
@@ -100,14 +102,14 @@
|
||||
{
|
||||
icon: ThumbsUp,
|
||||
title: 'Always Free',
|
||||
description: 'No sign-up, no fees. Just honest price comparisons for homeowners.',
|
||||
description: 'No sign-up, no fees. Honest price comparisons and service directories for homeowners.',
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Biz Hero - Compare Heating Oil Prices in New England</title>
|
||||
<meta name="description" content="Compare heating oil and biofuel prices across all six New England states. Find the best deals from local dealers in your county." />
|
||||
<title>LocalOilPrices - Heating Oil Prices & Service Companies in New England</title>
|
||||
<meta name="description" content="Compare heating oil and biofuel prices across all six New England states. Find the best deals from local dealers and locate boiler service companies in your county." />
|
||||
</svelte:head>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
@@ -128,13 +130,13 @@
|
||||
<h1 class="text-3xl sm:text-4xl md:text-5xl font-bold leading-tight text-base-content mb-4 px-2">
|
||||
Find the <span class="text-primary">Best Heating Oil Prices</span>
|
||||
<br class="hidden sm:block" />
|
||||
in New England
|
||||
& Service Companies in New England
|
||||
</h1>
|
||||
|
||||
<!-- Subheadline -->
|
||||
<p class="text-lg sm:text-xl text-base-content/70 max-w-2xl mx-auto mb-6 px-4 leading-relaxed">
|
||||
Compare prices from local dealers across all six states.
|
||||
<span class="hidden sm:inline">Save money on heating oil and biofuel this season.</span>
|
||||
<span class="hidden sm:inline">Find boiler & furnace service companies — including emergency no-heat response.</span>
|
||||
</p>
|
||||
|
||||
<!-- CTA hint -->
|
||||
@@ -279,10 +281,10 @@
|
||||
{#if valueVisible}
|
||||
<div in:fly={{ y: 20, duration: 400 }}>
|
||||
<h2 class="text-2xl sm:text-3xl font-bold text-center text-base-content mb-2">
|
||||
Why Use Biz Hero?
|
||||
Why Use LocalOilPrices?
|
||||
</h2>
|
||||
<p class="text-base text-base-content/60 text-center mb-8 sm:mb-10">
|
||||
Helping New England homeowners make smarter heating decisions
|
||||
Helping New England homeowners find the best oil prices and trusted service companies
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 max-w-5xl mx-auto">
|
||||
@@ -314,9 +316,9 @@
|
||||
<!-- ============================================================ -->
|
||||
{#if !$user}
|
||||
<section class="pb-8 text-center">
|
||||
<div class="inline-flex flex-col items-center gap-2 px-6 py-4 rounded-xl bg-base-200/40 dark:bg-base-200/20 border border-base-300/40">
|
||||
<div class="inline-flex flex-col items-center gap-3 px-6 py-4 rounded-xl bg-base-200/40 dark:bg-base-200/20 border border-base-300/40">
|
||||
<p class="text-sm text-base-content/50">
|
||||
Are you a heating oil vendor?
|
||||
Are you a heating oil or service company?
|
||||
</p>
|
||||
<a
|
||||
href="/login"
|
||||
@@ -328,3 +330,18 @@
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- DEVELOPER API LINK -->
|
||||
<!-- ============================================================ -->
|
||||
<section class="pb-6 text-center">
|
||||
<a
|
||||
href="{PUBLIC_API_URL}/api-directory"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm text-base-content/50 hover:text-primary transition-colors inline-flex items-center gap-1 font-medium"
|
||||
>
|
||||
Developer API Directory
|
||||
<ChevronRight size={14} />
|
||||
</a>
|
||||
</section>
|
||||
|
||||
@@ -173,10 +173,10 @@
|
||||
<!-- SEO -->
|
||||
<svelte:head>
|
||||
{#if stateData}
|
||||
<title>Heating Oil Prices in {stateData.name} | Biz Hero</title>
|
||||
<meta name="description" content="Compare heating oil and biofuel prices across all {stateData.name} counties. Find the best deals from local dealers near you." />
|
||||
<title>Heating Oil Prices & Service Companies in {stateData.name} | LocalOilPrices</title>
|
||||
<meta name="description" content="Compare heating oil prices and find local service companies across all {stateData.name} counties. Need heat? Find fuel dealers and HVAC service providers near you." />
|
||||
{:else}
|
||||
<title>State Not Found | Biz Hero</title>
|
||||
<title>State Not Found | LocalOilPrices</title>
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
@@ -222,7 +222,9 @@
|
||||
|
||||
<!-- Subtitle -->
|
||||
<p class="text-lg sm:text-xl text-base-content/70 max-w-xl mx-auto leading-relaxed px-4">
|
||||
Explore heating oil prices by county
|
||||
Explore heating oil prices & local service companies by county.
|
||||
<br/>
|
||||
<span class="text-base text-base-content/50">No heat? Find fuel dealers and emergency service providers near you.</span>
|
||||
{#if statePrice}
|
||||
<br/>
|
||||
<span class="inline-block mt-2 px-3 py-1 bg-base-200 rounded-full text-base font-semibold text-primary">
|
||||
@@ -307,7 +309,7 @@
|
||||
Counties in {stateData.name}
|
||||
</h2>
|
||||
<p class="text-base text-base-content/60 text-center mb-6 sm:mb-8">
|
||||
Select a county to view local heating oil dealers and prices
|
||||
Select a county to view local heating oil dealers, prices, and service companies
|
||||
</p>
|
||||
|
||||
{#if loading}
|
||||
@@ -365,7 +367,7 @@
|
||||
<span class="block text-sm sm:text-base font-semibold text-base-content truncate leading-tight">
|
||||
{county.name}
|
||||
</span>
|
||||
<span class="text-xs text-base-content/40">View prices</span>
|
||||
<span class="text-xs text-base-content/40">View prices & services</span>
|
||||
</div>
|
||||
|
||||
<ChevronRight
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { newEnglandStates } from '../../../../lib/states';
|
||||
import { PUBLIC_API_URL } from '$env/static/public';
|
||||
import { api } from '$lib/api';
|
||||
import type { Listing, OilPrice, County } from '$lib/api';
|
||||
import type { Listing, OilPrice, County, ServiceListing } from '$lib/api';
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
import {
|
||||
ChevronLeft,
|
||||
@@ -26,6 +27,7 @@
|
||||
let countyData: County | null = null;
|
||||
let listings: Listing[] = [];
|
||||
let oilPrices: OilPrice[] = [];
|
||||
let serviceListings: ServiceListing[] = [];
|
||||
let loading = true;
|
||||
let listingsLoading = false;
|
||||
let error: string | null = null;
|
||||
@@ -33,6 +35,9 @@
|
||||
let sortColumn = 'price_per_gallon';
|
||||
let sortDirection = 'asc';
|
||||
|
||||
// Active tab: 'fuel' or 'service'
|
||||
let activeTab: 'fuel' | 'service' = 'fuel';
|
||||
|
||||
// Market Prices sorting
|
||||
let marketSortColumn = 'date';
|
||||
let marketSortDirection = 'desc';
|
||||
@@ -67,9 +72,10 @@
|
||||
listingsLoading = true;
|
||||
listingsError = null;
|
||||
|
||||
const [listingsResult, oilPricesResult] = await Promise.all([
|
||||
const [listingsResult, oilPricesResult, serviceResult] = await Promise.all([
|
||||
api.listings.getByCounty(countyData.id),
|
||||
api.oilPrices.getByCounty(countyData.id)
|
||||
api.oilPrices.getByCounty(countyData.id),
|
||||
api.serviceListings.getByCounty(countyData.id)
|
||||
]);
|
||||
|
||||
if (listingsResult.error) {
|
||||
@@ -83,6 +89,9 @@
|
||||
oilPrices = (oilPricesResult.data || []).filter(p => p.price !== 0);
|
||||
sortMarketPrices(); // Sort immediately after fetching
|
||||
|
||||
// Service listings failure is non-critical
|
||||
serviceListings = serviceResult.data || [];
|
||||
|
||||
listingsLoading = false;
|
||||
}
|
||||
|
||||
@@ -226,10 +235,10 @@
|
||||
<!-- SEO -->
|
||||
<svelte:head>
|
||||
{#if countyData}
|
||||
<title>Heating Oil Prices in {countyData.name}, {getStateName(countyData.state)} | Biz Hero</title>
|
||||
<title>Heating Oil Prices in {countyData.name}, {getStateName(countyData.state)} | LocalOilPrices</title>
|
||||
<meta name="description" content="Compare heating oil and biofuel prices from local dealers in {countyData.name}, {getStateName(countyData.state)}. Find the best deals today." />
|
||||
{:else}
|
||||
<title>County Not Found | Biz Hero</title>
|
||||
<title>County Not Found | LocalOilPrices</title>
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
@@ -316,15 +325,46 @@
|
||||
|
||||
<!-- Subtitle -->
|
||||
<p class="text-base text-base-content/50 max-w-xl mx-auto leading-relaxed px-4">
|
||||
Compare heating oil prices from local dealers
|
||||
Compare heating oil prices and find local service companies
|
||||
</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- FUEL / SERVICE TABS -->
|
||||
<!-- ============================================================ -->
|
||||
{#if headerVisible}
|
||||
<div class="flex justify-center mb-2 px-2" in:fade={{ duration: 300 }}>
|
||||
<div class="tabs tabs-boxed bg-base-200/70 gap-1 p-1">
|
||||
<button
|
||||
class="tab gap-2 font-semibold {activeTab === 'fuel' ? 'tab-active' : ''}"
|
||||
on:click={() => { activeTab = 'fuel'; }}
|
||||
>
|
||||
<Flame size={16} />
|
||||
Fuel Prices
|
||||
{#if listings.filter(l => l.phone).length > 0}
|
||||
<span class="badge badge-sm badge-primary">{listings.filter(l => l.phone).length}</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
class="tab gap-2 font-semibold {activeTab === 'service' ? 'tab-active' : ''}"
|
||||
on:click={() => { activeTab = 'service'; }}
|
||||
>
|
||||
<Wrench size={16} />
|
||||
Service Companies
|
||||
{#if serviceListings.length > 0}
|
||||
<span class="badge badge-sm badge-success">{serviceListings.length}</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- LISTINGS CONTENT -->
|
||||
<!-- ============================================================ -->
|
||||
{#if activeTab === 'fuel'}
|
||||
|
||||
{#if listingsLoading}
|
||||
<!-- Skeleton loading for listings -->
|
||||
<div class="px-2 mt-4">
|
||||
@@ -520,16 +560,25 @@
|
||||
>
|
||||
<!-- Company name -->
|
||||
<td class="px-4 py-4">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-base font-semibold text-base-content">
|
||||
{listing.company_name}
|
||||
</span>
|
||||
{#if listing.url}
|
||||
<a href={listing.url} target="_blank" rel="noopener noreferrer" class="text-xs text-primary hover:underline flex items-center gap-1 mt-0.5" on:click|stopPropagation>
|
||||
<Globe size={10} />
|
||||
Visit Website
|
||||
<div class="flex gap-3 items-center">
|
||||
<div class="w-16 h-16 rounded-full overflow-hidden bg-primary flex items-center justify-center flex-shrink-0">
|
||||
{#if listing.logo_url}
|
||||
<img src="{PUBLIC_API_URL}{listing.logo_url}" alt="" class="w-full h-full object-cover" />
|
||||
{:else}
|
||||
<span class="text-xl text-primary-content font-bold">{listing.company_name.charAt(0).toUpperCase()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<a href="/listing/{listing.id}" class="text-base font-semibold text-base-content hover:text-primary hover:underline transition-colors">
|
||||
{listing.company_name}
|
||||
</a>
|
||||
{/if}
|
||||
{#if listing.url}
|
||||
<a href={listing.url} target="_blank" rel="noopener noreferrer" class="text-xs text-primary hover:underline flex items-center gap-1 mt-0.5" on:click|stopPropagation>
|
||||
<Globe size={10} />
|
||||
Visit Website
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -588,9 +637,13 @@
|
||||
{:else}
|
||||
<span class="text-sm text-base-content/40"></span>
|
||||
{/if}
|
||||
<div class="text-xs text-base-content/40 flex items-center gap-1">
|
||||
<Globe size={11} />
|
||||
{formatOnlineOrdering(listing.online_ordering)}
|
||||
<div class="text-xs flex items-center gap-1">
|
||||
<Globe size={11} class="text-base-content/40" />
|
||||
{#if (listing.online_ordering === 'both' || listing.online_ordering === 'online_only') && listing.url}
|
||||
<a href={listing.url} target="_blank" rel="noopener noreferrer" class="font-medium text-primary hover:underline">Order online !</a>
|
||||
{:else}
|
||||
<span class="text-base-content/40">{formatOnlineOrdering(listing.online_ordering)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -646,12 +699,27 @@
|
||||
<div class="lg:hidden space-y-3">
|
||||
{#each listings.filter(l => l.phone) as listing, i}
|
||||
<div class="listing-card stagger-{(i % 6) + 1} opacity-0 animate-fade-in-up">
|
||||
{#if listing.banner_url}
|
||||
<div class="mb-4 block lg:hidden w-full max-w-[400px] mx-auto aspect-[3/1] rounded-lg overflow-hidden bg-base-300">
|
||||
<img src="{PUBLIC_API_URL}{listing.banner_url}" alt="{listing.company_name} banner" class="w-full h-full object-cover" />
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Top row: Company + Price -->
|
||||
<div class="flex items-start justify-between gap-3 mb-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-lg font-bold text-base-content leading-tight truncate">
|
||||
{listing.company_name}
|
||||
</h3>
|
||||
<div class="flex gap-3 min-w-0 flex-1">
|
||||
<div class="w-16 h-16 rounded-full overflow-hidden bg-primary flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
{#if listing.logo_url}
|
||||
<img src="{PUBLIC_API_URL}{listing.logo_url}" alt="" class="w-full h-full object-cover" />
|
||||
{:else}
|
||||
<span class="text-2xl text-primary-content font-bold">{listing.company_name.charAt(0).toUpperCase()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-lg font-bold leading-tight truncate">
|
||||
<a href="/listing/{listing.id}" class="text-base-content hover:text-primary hover:underline transition-colors">
|
||||
{listing.company_name}
|
||||
</a>
|
||||
</h3>
|
||||
{#if listing.url}
|
||||
<a href={listing.url} target="_blank" rel="noopener noreferrer" class="text-xs text-primary hover:underline flex items-center gap-1 mt-0.5" on:click|stopPropagation>
|
||||
<Globe size={11} />
|
||||
@@ -664,6 +732,7 @@
|
||||
{listing.town}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right flex-shrink-0">
|
||||
<div class="price-hero !text-2xl">
|
||||
@@ -707,7 +776,11 @@
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Globe size={14} class="text-base-content/40 flex-shrink-0" />
|
||||
<span class="text-base-content/60">Order:</span>
|
||||
<span class="font-medium text-base-content">{formatOnlineOrdering(listing.online_ordering)}</span>
|
||||
{#if (listing.online_ordering === 'both' || listing.online_ordering === 'online_only') && listing.url}
|
||||
<a href={listing.url} target="_blank" rel="noopener noreferrer" class="font-medium text-primary hover:underline">Order online !</a>
|
||||
{:else}
|
||||
<span class="font-medium text-base-content">{formatOnlineOrdering(listing.online_ordering)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Last updated -->
|
||||
@@ -930,6 +1003,210 @@
|
||||
|
||||
{/if}
|
||||
|
||||
<!-- Close fuel tab -->
|
||||
{:else if activeTab === 'service'}
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- SERVICE COMPANIES TAB -->
|
||||
<!-- ============================================================ -->
|
||||
<section class="px-2 mt-4">
|
||||
<div class="price-section">
|
||||
<div class="mb-4">
|
||||
<h2 class="text-2xl sm:text-3xl font-bold text-base-content flex items-center gap-2">
|
||||
<div class="value-icon !w-9 !h-9 sm:!w-10 sm:!h-10 flex-shrink-0">
|
||||
<Wrench size={20} />
|
||||
</div>
|
||||
Service Companies
|
||||
</h2>
|
||||
<p class="text-base text-base-content/50 mt-1">
|
||||
Local heating oil service & 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) -->
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
UpdateCompanyRequest,
|
||||
UpdateListingRequest,
|
||||
UpdateOilPriceRequest,
|
||||
Banner,
|
||||
} from "$lib/api/types";
|
||||
|
||||
let activeTab = "listings";
|
||||
@@ -20,6 +21,12 @@
|
||||
let loading = false;
|
||||
let error: string | null = null;
|
||||
|
||||
// Banner management state
|
||||
let currentBanner: Banner | null = null;
|
||||
let newBannerMessage = '';
|
||||
let bannerLoading = false;
|
||||
let bannerSuccess = '';
|
||||
|
||||
const tabs = [
|
||||
{ id: "listings", label: "Listings" },
|
||||
{ id: "companies", label: "Companies" },
|
||||
@@ -105,8 +112,45 @@
|
||||
return;
|
||||
}
|
||||
await loadData();
|
||||
await loadBanner();
|
||||
});
|
||||
|
||||
async function loadBanner() {
|
||||
bannerLoading = true;
|
||||
const result = await api.banner.getActive();
|
||||
if (!result.error) {
|
||||
currentBanner = result.data || null;
|
||||
}
|
||||
bannerLoading = false;
|
||||
}
|
||||
|
||||
async function createBanner() {
|
||||
if (!newBannerMessage.trim()) return;
|
||||
bannerLoading = true;
|
||||
bannerSuccess = '';
|
||||
const result = await api.banner.create(newBannerMessage.trim());
|
||||
if (result.data) {
|
||||
currentBanner = result.data;
|
||||
newBannerMessage = '';
|
||||
bannerSuccess = 'Banner created!';
|
||||
setTimeout(() => { bannerSuccess = ''; }, 3000);
|
||||
}
|
||||
bannerLoading = false;
|
||||
}
|
||||
|
||||
async function deactivateBanner() {
|
||||
if (!currentBanner) return;
|
||||
bannerLoading = true;
|
||||
bannerSuccess = '';
|
||||
const result = await api.banner.delete(currentBanner.id);
|
||||
if (!result.error) {
|
||||
currentBanner = null;
|
||||
bannerSuccess = 'Banner deactivated!';
|
||||
setTimeout(() => { bannerSuccess = ''; }, 3000);
|
||||
}
|
||||
bannerLoading = false;
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading = true;
|
||||
error = null;
|
||||
@@ -215,6 +259,56 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Banner Management -->
|
||||
<div class="card bg-base-100 shadow-md border border-warning/30 mb-6">
|
||||
<div class="card-body p-5">
|
||||
<h2 class="text-lg font-bold mb-3 flex items-center gap-2">
|
||||
⚠️ Site Banner
|
||||
{#if currentBanner}
|
||||
<span class="badge badge-warning">Active</span>
|
||||
{:else}
|
||||
<span class="badge badge-neutral">None</span>
|
||||
{/if}
|
||||
</h2>
|
||||
|
||||
{#if bannerSuccess}
|
||||
<div class="alert alert-success mb-3 py-2">
|
||||
<span>{bannerSuccess}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if currentBanner}
|
||||
<div class="bg-warning/10 border border-warning/30 rounded-lg p-3 mb-3">
|
||||
<p class="text-sm font-medium">{currentBanner.message}</p>
|
||||
<p class="text-xs text-base-content/40 mt-1">Created: {new Date(currentBanner.created_at || '').toLocaleString()}</p>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-error btn-sm"
|
||||
on:click={deactivateBanner}
|
||||
disabled={bannerLoading}
|
||||
>
|
||||
{bannerLoading ? 'Deactivating...' : 'Deactivate Banner'}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2 mt-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newBannerMessage}
|
||||
placeholder="Enter new banner message..."
|
||||
class="input input-bordered flex-1 input-sm"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-warning btn-sm"
|
||||
on:click={createBanner}
|
||||
disabled={bannerLoading || !newBannerMessage.trim()}
|
||||
>
|
||||
{bannerLoading ? 'Saving...' : currentBanner ? 'Replace Banner' : 'Create Banner'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs tabs-boxed mb-4">
|
||||
{#each tabs as tab}
|
||||
<button
|
||||
|
||||
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">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import type { Listing, Company } from '$lib/api';
|
||||
import { PUBLIC_API_URL } from '$env/static/public';
|
||||
import type { Listing, Company, ServiceListing, Subscription } from '$lib/api';
|
||||
|
||||
let listings: Listing[] = [];
|
||||
let serviceListings: ServiceListing[] = [];
|
||||
let company: Company | null = null;
|
||||
let isLoading = true;
|
||||
let serviceLoading = true;
|
||||
let companyLoading = true;
|
||||
let error = '';
|
||||
let serviceError = '';
|
||||
let companyError = '';
|
||||
let successMessage = '';
|
||||
let subscription: Subscription | null = null;
|
||||
let subscriptionLoading = true;
|
||||
|
||||
// Inline editing state
|
||||
let editingId: number | null = null;
|
||||
@@ -34,7 +40,7 @@
|
||||
|
||||
async function fetchListings() {
|
||||
const result = await api.listing.getAll();
|
||||
|
||||
|
||||
if (result.error) {
|
||||
if (result.error !== 'Session expired. Please log in again.') {
|
||||
error = 'Failed to load listings';
|
||||
@@ -45,6 +51,28 @@
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
async function fetchServiceListings() {
|
||||
const result = await api.serviceListing.getAll();
|
||||
if (result.error) {
|
||||
if (result.error !== 'Session expired. Please log in again.') {
|
||||
serviceError = 'Failed to load service listings';
|
||||
}
|
||||
} else {
|
||||
serviceListings = result.data || [];
|
||||
}
|
||||
serviceLoading = false;
|
||||
}
|
||||
|
||||
async function deleteServiceListing(id: number) {
|
||||
if (!confirm('Are you sure you want to delete this service listing?')) return;
|
||||
const result = await api.serviceListing.delete(id);
|
||||
if (result.error) {
|
||||
alert('Failed to delete service listing');
|
||||
} else {
|
||||
serviceListings = serviceListings.filter(l => l.id !== id);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteListing(id: number) {
|
||||
if (!confirm('Are you sure you want to delete this listing?')) return;
|
||||
|
||||
@@ -152,6 +180,25 @@
|
||||
return phone;
|
||||
}
|
||||
|
||||
async function fetchSubscription() {
|
||||
const result = await api.subscription.get();
|
||||
if (result.error) {
|
||||
// Subscription not found is okay (no company yet)
|
||||
subscription = null;
|
||||
} else {
|
||||
subscription = result.data;
|
||||
}
|
||||
subscriptionLoading = false;
|
||||
}
|
||||
|
||||
function getTrialDaysRemaining(): number {
|
||||
if (!subscription) return 0;
|
||||
const end = new Date(subscription.trial_end);
|
||||
const now = new Date();
|
||||
const diff = end.getTime() - now.getTime();
|
||||
return Math.max(0, Math.ceil(diff / (1000 * 60 * 60 * 24)));
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!api.auth.isAuthenticated()) {
|
||||
window.location.href = '/login';
|
||||
@@ -159,11 +206,21 @@
|
||||
}
|
||||
fetchCompany();
|
||||
fetchListings();
|
||||
fetchServiceListings();
|
||||
fetchSubscription();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto p-4">
|
||||
<h1 class="text-3xl font-bold mb-6">Vendor Dashboard</h1>
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-6 gap-4">
|
||||
<h1 class="text-3xl font-bold">Vendor Dashboard</h1>
|
||||
<div class="px-4 py-2 bg-info/10 text-info-content rounded-lg border border-info/20 flex items-center gap-2 shadow-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 text-info">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.25 9.75v-4.5m0 4.5h4.5m-4.5 0 6-6m-3 18c-8.284 0-15-6.716-15-15V4.5A2.25 2.25 0 0 1 4.5 2.25h1.372c.516 0 .966.351 1.091.852l1.106 4.423c.11.44-.054.902-.417 1.173l-1.293.97a17.14 17.14 0 0 0 7.043 7.043l.97-1.293c.271-.363.734-.527 1.173-.417l4.423 1.106c.5.125.852.575.852 1.091V19.5a2.25 2.25 0 0 1-2.25 2.25h-2.25Z" />
|
||||
</svg>
|
||||
<span class="font-medium text-sm">Need help or support? Call <a href="tel:7743342638" class="font-bold hover:underline">774 334 2638</a></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Banner -->
|
||||
{#if successMessage}
|
||||
@@ -256,12 +313,50 @@
|
||||
<!-- Navigation Links -->
|
||||
<div class="flex flex-wrap gap-4 mb-8">
|
||||
<a href="/vendor/profile" class="btn btn-outline">Company Profile</a>
|
||||
<a href="/vendor/listing" class="btn btn-primary">Create Listing</a>
|
||||
<a href="/vendor/listing" class="btn btn-primary">Add Oil Company Listing</a>
|
||||
<a href="/vendor/service" class="btn btn-success">Add Service Listing</a>
|
||||
</div>
|
||||
|
||||
<!-- Subscription Status Card -->
|
||||
{#if !subscriptionLoading && subscription}
|
||||
{@const daysRemaining = getTrialDaysRemaining()}
|
||||
<div class="card bg-base-100 shadow-xl mb-8 border-2 {subscription.status === 'trial' ? 'border-warning/30' : 'border-success/30'}">
|
||||
<div class="card-body p-5">
|
||||
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-base-content flex items-center gap-2">
|
||||
🔥 Subscription Status
|
||||
{#if subscription.status === 'trial'}
|
||||
<span class="badge badge-warning">Free Trial</span>
|
||||
{:else if subscription.status === 'active'}
|
||||
<span class="badge badge-success">Active</span>
|
||||
{:else}
|
||||
<span class="badge badge-error">{subscription.status}</span>
|
||||
{/if}
|
||||
</h2>
|
||||
{#if subscription.status === 'trial'}
|
||||
<p class="text-sm text-base-content/60 mt-1">
|
||||
Your 1 year free trial {daysRemaining > 0 ? `ends on ${new Date(subscription.trial_end).toLocaleDateString()}` : 'has expired'}.
|
||||
{#if daysRemaining > 0}
|
||||
<span class="font-semibold text-primary">{daysRemaining} days remaining</span>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if subscription.status === 'trial' && daysRemaining > 0}
|
||||
<div class="text-center px-4 py-2 rounded-xl bg-warning/10">
|
||||
<div class="text-2xl font-bold text-warning">{daysRemaining}</div>
|
||||
<div class="text-xs text-base-content/50">days left</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Listings Table -->
|
||||
<div class="mt-8">
|
||||
<h2 class="text-2xl font-bold mb-4">Your Listings</h2>
|
||||
<h2 class="text-2xl font-bold mb-4">Your Oil Company Listings</h2>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="flex justify-center">
|
||||
@@ -273,8 +368,8 @@
|
||||
</div>
|
||||
{:else if listings.length === 0}
|
||||
<div class="text-center py-8">
|
||||
<p class="text-lg text-gray-600">No listings found.. create one :)</p>
|
||||
<a href="/vendor/listing" class="btn btn-primary mt-4">Create Listing</a>
|
||||
<p class="text-lg text-gray-600">No oil listings found. Create one to get started.</p>
|
||||
<a href="/vendor/listing" class="btn btn-primary mt-4">Add Oil Company Listing</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
@@ -296,7 +391,18 @@
|
||||
<tbody>
|
||||
{#each listings.filter(l => l.phone) as listing}
|
||||
<tr>
|
||||
<td>{listing.company_name}</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full overflow-hidden bg-primary flex items-center justify-center flex-shrink-0">
|
||||
{#if listing.logo_url}
|
||||
<img src="{PUBLIC_API_URL}{listing.logo_url}" alt="" class="w-full h-full object-cover" />
|
||||
{:else}
|
||||
<span class="text-sm text-primary-content font-bold">{listing.company_name.charAt(0).toUpperCase()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span>{listing.company_name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{#if listing.town}
|
||||
{listing.town} (ID: {listing.county_id})
|
||||
@@ -458,4 +564,114 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Service Listings Section -->
|
||||
<div class="mt-12">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-2xl font-bold">Your Service Listings</h2>
|
||||
<a href="/vendor/service" class="btn btn-success btn-sm">+ Add Service Listing</a>
|
||||
</div>
|
||||
|
||||
{#if serviceLoading}
|
||||
<div class="flex justify-center">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
{:else if serviceError}
|
||||
<div class="alert alert-error">
|
||||
<span>{serviceError}</span>
|
||||
</div>
|
||||
{:else if serviceListings.length === 0}
|
||||
<div class="text-center py-8 bg-base-200/50 rounded-xl border border-base-300/60">
|
||||
<p class="text-lg text-base-content/60 mb-2">No service listings yet.</p>
|
||||
<p class="text-sm text-base-content/40 mb-4">Add a service listing if you offer boiler/furnace repair and maintenance.</p>
|
||||
<a href="/vendor/service" class="btn btn-success btn-sm">Add Service Listing</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Company</th>
|
||||
<th>Location</th>
|
||||
<th>Phone</th>
|
||||
<th>24hr</th>
|
||||
<th>Emergency</th>
|
||||
<th>Licensed</th>
|
||||
<th>Active</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each serviceListings as listing}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full overflow-hidden bg-success flex items-center justify-center flex-shrink-0">
|
||||
{#if listing.logo_url}
|
||||
<img src="{PUBLIC_API_URL}{listing.logo_url}" alt="" class="w-full h-full object-cover" />
|
||||
{:else}
|
||||
<span class="text-sm text-success-content font-bold">{listing.company_name.charAt(0).toUpperCase()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium">{listing.company_name}</div>
|
||||
{#if listing.service_area}
|
||||
<div class="text-xs text-base-content/50">{listing.service_area}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{listing.town || ''}
|
||||
{#if listing.county_id}
|
||||
<span class="text-xs text-base-content/40">(County {listing.county_id})</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>{listing.phone || '—'}</td>
|
||||
<td>
|
||||
{#if listing.twenty_four_hour}
|
||||
<span class="badge badge-success badge-sm">24hr</span>
|
||||
{:else}
|
||||
<span class="badge badge-neutral badge-sm">No</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
{#if listing.emergency_service}
|
||||
<span class="badge badge-warning badge-sm">Yes</span>
|
||||
{:else}
|
||||
<span class="badge badge-neutral badge-sm">No</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
{#if listing.licensed_insured}
|
||||
<span class="badge badge-info badge-sm">Yes</span>
|
||||
{:else}
|
||||
<span class="badge badge-neutral badge-sm">No</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
{#if listing.is_active}
|
||||
<span class="badge badge-success">Active</span>
|
||||
{:else}
|
||||
<span class="badge badge-neutral">Inactive</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex gap-2">
|
||||
<a href="/vendor/service/{listing.id}" class="btn btn-sm btn-primary">Edit</a>
|
||||
<button
|
||||
class="btn btn-sm btn-error"
|
||||
on:click={() => deleteServiceListing(listing.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
57
src/routes/(app)/vendor/listing/+page.svelte
vendored
57
src/routes/(app)/vendor/listing/+page.svelte
vendored
@@ -16,9 +16,23 @@
|
||||
state: '',
|
||||
countyId: 0,
|
||||
town: '',
|
||||
url: ''
|
||||
url: '',
|
||||
townsServiced: [] as string[]
|
||||
};
|
||||
|
||||
let newTown = '';
|
||||
|
||||
function addTown() {
|
||||
if (newTown.trim() && !formData.townsServiced.includes(newTown.trim())) {
|
||||
formData.townsServiced = [...formData.townsServiced, newTown.trim()];
|
||||
newTown = '';
|
||||
}
|
||||
}
|
||||
|
||||
function removeTown(townToRemove: string) {
|
||||
formData.townsServiced = formData.townsServiced.filter(town => town !== townToRemove);
|
||||
}
|
||||
|
||||
// Active status
|
||||
let isActive = true;
|
||||
|
||||
@@ -146,7 +160,8 @@
|
||||
county_id: formData.countyId,
|
||||
|
||||
town: formData.town.trim() || null,
|
||||
url: formData.url.trim() || null
|
||||
url: formData.url.trim() || null,
|
||||
towns_serviced: formData.townsServiced.length > 0 ? formData.townsServiced : null
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
@@ -249,10 +264,10 @@
|
||||
</label>
|
||||
<input
|
||||
id="url"
|
||||
type="url"
|
||||
type="text"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={formData.url}
|
||||
placeholder="https://example.com"
|
||||
placeholder="example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -444,6 +459,40 @@
|
||||
<span class="label-text-alt">Specify the town within the selected county</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Additional Towns Serviced Input -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="newTown">
|
||||
<span class="label-text">Additional Towns Serviced (Optional)</span>
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
id="newTown"
|
||||
type="text"
|
||||
class="input input-bordered flex-1"
|
||||
bind:value={newTown}
|
||||
on:keydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addTown(); } }}
|
||||
placeholder="Type a town and press Add"
|
||||
/>
|
||||
<button type="button" class="btn btn-secondary" on:click={addTown}>Add</button>
|
||||
</div>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">Add any other towns your company services</span>
|
||||
</label>
|
||||
|
||||
{#if formData.townsServiced.length > 0}
|
||||
<div class="flex flex-wrap gap-2 mt-2">
|
||||
{#each formData.townsServiced as town}
|
||||
<div class="badge badge-primary gap-1 p-3">
|
||||
{town}
|
||||
<button type="button" class="hover:text-error" aria-label="Remove {town}" on:click={() => removeTown(town)}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="inline-block w-4 h-4 stroke-current"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
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 { newEnglandStates } from '$lib/states';
|
||||
import { api } from '$lib/api';
|
||||
import { PUBLIC_API_URL } from '$env/static/public';
|
||||
import ImageCropModal from '$lib/components/ImageCropModal.svelte';
|
||||
import BannerCropModal from '$lib/components/BannerCropModal.svelte';
|
||||
import type { County, Listing } from '$lib/api';
|
||||
|
||||
// Get listing ID from URL params
|
||||
@@ -22,9 +25,26 @@
|
||||
state: '',
|
||||
countyId: 0,
|
||||
town: '',
|
||||
url: ''
|
||||
url: '',
|
||||
facebookUrl: '',
|
||||
instagramUrl: '',
|
||||
googleBusinessUrl: '',
|
||||
townsServiced: [] as string[]
|
||||
};
|
||||
|
||||
let newTown = '';
|
||||
|
||||
function addTown() {
|
||||
if (newTown.trim() && !formData.townsServiced.includes(newTown.trim())) {
|
||||
formData.townsServiced = [...formData.townsServiced, newTown.trim()];
|
||||
newTown = '';
|
||||
}
|
||||
}
|
||||
|
||||
function removeTown(townToRemove: string) {
|
||||
formData.townsServiced = formData.townsServiced.filter(town => town !== townToRemove);
|
||||
}
|
||||
|
||||
// Active status
|
||||
let isActive = true;
|
||||
|
||||
@@ -52,6 +72,20 @@
|
||||
let submitMessage = '';
|
||||
let isLoading = true;
|
||||
|
||||
// Image upload state
|
||||
let currentLogoUrl: string | null = null;
|
||||
let imageUploading = false;
|
||||
let imageError = '';
|
||||
let showCropModal = false;
|
||||
let cropSrc = '';
|
||||
|
||||
// Banner upload state
|
||||
let currentBannerUrl: string | null = null;
|
||||
let bannerUploading = false;
|
||||
let bannerError = '';
|
||||
let showBannerCropModal = false;
|
||||
let bannerCropSrc = '';
|
||||
|
||||
// Location selector state
|
||||
let counties: County[] = [];
|
||||
let isLoadingCounties = false;
|
||||
@@ -80,6 +114,12 @@
|
||||
formData.countyId = listing.county_id;
|
||||
formData.town = listing.town || '';
|
||||
formData.url = listing.url || '';
|
||||
formData.facebookUrl = listing.facebook_url || '';
|
||||
formData.instagramUrl = listing.instagram_url || '';
|
||||
formData.googleBusinessUrl = listing.google_business_url || '';
|
||||
formData.townsServiced = listing.towns_serviced || [];
|
||||
currentLogoUrl = listing.logo_url;
|
||||
currentBannerUrl = listing.banner_url || null;
|
||||
|
||||
// Load the state for this county
|
||||
await loadStateForCounty(listing.county_id);
|
||||
@@ -203,7 +243,11 @@
|
||||
online_ordering: onlineOrdering,
|
||||
county_id: formData.countyId,
|
||||
town: formData.town.trim() || null,
|
||||
url: formData.url.trim() || null
|
||||
url: formData.url.trim() || null,
|
||||
facebook_url: formData.facebookUrl.trim() || null,
|
||||
instagram_url: formData.instagramUrl.trim() || null,
|
||||
google_business_url: formData.googleBusinessUrl.trim() || null,
|
||||
towns_serviced: formData.townsServiced.length > 0 ? formData.townsServiced : null
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
@@ -243,11 +287,120 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleImageSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (!input.files || input.files.length === 0) return;
|
||||
const file = input.files[0];
|
||||
input.value = '';
|
||||
imageError = '';
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
cropSrc = e.target?.result as string;
|
||||
showCropModal = true;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
async function handleCrop(event: CustomEvent<Blob>) {
|
||||
const blob = event.detail;
|
||||
showCropModal = false;
|
||||
cropSrc = '';
|
||||
imageError = '';
|
||||
imageUploading = true;
|
||||
|
||||
const file = new File([blob], 'logo.jpg', { type: 'image/jpeg' });
|
||||
const result = await api.upload.uploadListingImage(parseInt(listingId), file);
|
||||
if (result.error) {
|
||||
imageError = result.error;
|
||||
} else if (result.data) {
|
||||
currentLogoUrl = result.data.logo_url;
|
||||
}
|
||||
|
||||
imageUploading = false;
|
||||
}
|
||||
|
||||
function handleCropCancel() {
|
||||
showCropModal = false;
|
||||
cropSrc = '';
|
||||
}
|
||||
|
||||
async function handleImageDelete() {
|
||||
if (!confirm('Remove the company logo?')) return;
|
||||
imageError = '';
|
||||
imageUploading = true;
|
||||
|
||||
const result = await api.upload.deleteListingImage(parseInt(listingId));
|
||||
if (result.error) {
|
||||
imageError = result.error;
|
||||
} else {
|
||||
currentLogoUrl = null;
|
||||
}
|
||||
|
||||
imageUploading = false;
|
||||
}
|
||||
|
||||
function handleBannerSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (!input.files || input.files.length === 0) return;
|
||||
const file = input.files[0];
|
||||
input.value = '';
|
||||
bannerError = '';
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
bannerCropSrc = e.target?.result as string;
|
||||
showBannerCropModal = true;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
async function handleBannerCrop(event: CustomEvent<Blob>) {
|
||||
const blob = event.detail;
|
||||
showBannerCropModal = false;
|
||||
bannerCropSrc = '';
|
||||
bannerError = '';
|
||||
bannerUploading = true;
|
||||
|
||||
const file = new File([blob], 'banner.jpg', { type: 'image/jpeg' });
|
||||
const result = await api.upload.uploadListingBanner(parseInt(listingId), file);
|
||||
if (result.error) {
|
||||
bannerError = result.error;
|
||||
} else if (result.data) {
|
||||
currentBannerUrl = result.data.banner_url;
|
||||
}
|
||||
|
||||
bannerUploading = false;
|
||||
}
|
||||
|
||||
function handleBannerCropCancel() {
|
||||
showBannerCropModal = false;
|
||||
bannerCropSrc = '';
|
||||
}
|
||||
|
||||
async function handleBannerDelete() {
|
||||
if (!confirm('Remove the banner image?')) return;
|
||||
bannerError = '';
|
||||
bannerUploading = true;
|
||||
|
||||
const result = await api.upload.deleteListingBanner(parseInt(listingId));
|
||||
if (result.error) {
|
||||
bannerError = result.error;
|
||||
} else {
|
||||
currentBannerUrl = null;
|
||||
}
|
||||
|
||||
bannerUploading = false;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadListing();
|
||||
});
|
||||
</script>
|
||||
|
||||
<ImageCropModal open={showCropModal} imageSrc={cropSrc} on:crop={handleCrop} on:cancel={handleCropCancel} />
|
||||
<BannerCropModal open={showBannerCropModal} imageSrc={bannerCropSrc} on:crop={handleBannerCrop} on:cancel={handleBannerCropCancel} />
|
||||
|
||||
<!-- Breadcrumbs (full width, left aligned) -->
|
||||
<nav class="breadcrumbs text-sm mb-4 px-4">
|
||||
<ul>
|
||||
@@ -270,6 +423,114 @@
|
||||
<a href="/vendor" class="text-blue-500 hover:underline">Back to Vendor Dashboard</a>
|
||||
</div>
|
||||
|
||||
<!-- Company Logo Card -->
|
||||
<div class="card bg-base-100 shadow-xl mb-8">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-xl mb-4">Company Logo</h2>
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<!-- Logo preview 170×170 -->
|
||||
<div class="w-[170px] h-[170px] rounded-full overflow-hidden bg-primary flex items-center justify-center flex-shrink-0">
|
||||
{#if currentLogoUrl}
|
||||
<img src="{PUBLIC_API_URL}{currentLogoUrl}" alt="Company logo" class="w-full h-full object-cover" />
|
||||
{:else}
|
||||
<span class="text-5xl text-primary-content font-bold">
|
||||
{formData.companyName ? formData.companyName.charAt(0).toUpperCase() : '?'}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if imageError}
|
||||
<div class="alert alert-error w-full">
|
||||
<span>{imageError}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-wrap gap-2 justify-center">
|
||||
<label class="btn btn-primary {imageUploading ? 'btn-disabled' : ''}">
|
||||
{#if imageUploading}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
Uploading...
|
||||
{:else}
|
||||
Upload Logo
|
||||
{/if}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
class="hidden"
|
||||
on:change={handleImageSelect}
|
||||
disabled={imageUploading}
|
||||
/>
|
||||
</label>
|
||||
{#if currentLogoUrl}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-error btn-outline"
|
||||
on:click={handleImageDelete}
|
||||
disabled={imageUploading}
|
||||
>
|
||||
Remove Logo
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-sm text-base-content/50 text-center">JPEG, PNG, or WebP • 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 -->
|
||||
<div class="card bg-base-100 shadow-xl mb-8">
|
||||
<div class="card-body">
|
||||
@@ -315,10 +576,10 @@
|
||||
</label>
|
||||
<input
|
||||
id="url"
|
||||
type="url"
|
||||
type="text"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={formData.url}
|
||||
placeholder="https://example.com"
|
||||
placeholder="example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -510,6 +771,40 @@
|
||||
<span class="label-text-alt">Specify the town within the selected county</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Additional Towns Serviced Input -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="newTown">
|
||||
<span class="label-text">Additional Towns Serviced (Optional)</span>
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
id="newTown"
|
||||
type="text"
|
||||
class="input input-bordered flex-1"
|
||||
bind:value={newTown}
|
||||
on:keydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addTown(); } }}
|
||||
placeholder="Type a town and press Add"
|
||||
/>
|
||||
<button type="button" class="btn btn-secondary" on:click={addTown}>Add</button>
|
||||
</div>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">Add any other towns your company services</span>
|
||||
</label>
|
||||
|
||||
{#if formData.townsServiced.length > 0}
|
||||
<div class="flex flex-wrap gap-2 mt-2">
|
||||
{#each formData.townsServiced as town}
|
||||
<div class="badge badge-primary gap-1 p-3">
|
||||
{town}
|
||||
<button type="button" class="hover:text-error" aria-label="Remove {town}" on:click={() => removeTown(town)}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="inline-block w-4 h-4 stroke-current"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -560,6 +855,54 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Social Media & Business Links Section -->
|
||||
<div class="bg-base-200 p-4 rounded-lg">
|
||||
<h3 class="text-lg font-semibold mb-3">Social Media & Business Links</h3>
|
||||
<div class="space-y-4">
|
||||
<!-- Facebook URL -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="facebookUrl">
|
||||
<span class="label-text">Facebook Page URL (Optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="facebookUrl"
|
||||
type="text"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={formData.facebookUrl}
|
||||
placeholder="facebook.com/yourpage"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Instagram URL -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="instagramUrl">
|
||||
<span class="label-text">Instagram URL (Optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="instagramUrl"
|
||||
type="text"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={formData.instagramUrl}
|
||||
placeholder="instagram.com/yourpage"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Google Business URL -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="googleBusinessUrl">
|
||||
<span class="label-text">Google Business Page URL (Optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="googleBusinessUrl"
|
||||
type="text"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={formData.googleBusinessUrl}
|
||||
placeholder="g.page/r/... or similar"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="form-control mt-6">
|
||||
<button
|
||||
|
||||
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: *
|
||||
Disallow:
|
||||
Allow: /
|
||||
Sitemap: https://localoilprices.com/sitemap.xml
|
||||
@@ -6,7 +6,7 @@ export default defineConfig({
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://newenglandoil_api_rust:9552',
|
||||
target: 'http://localoilprices_api_rust:9552',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user