From c6ca35fcd975c06effdc580b07e812d7d0dbd47a Mon Sep 17 00:00:00 2001 From: Edwin Eames Date: Sun, 8 Mar 2026 15:12:55 -0400 Subject: [PATCH] feat: implement SEO improvements, listing profiles, service images, and towns serviced --- .../svelte-frontend-expert/MEMORY.md | 2 + Dockerfile | 12 +- Dockerfile.prod | 6 +- package-lock.json | 20 +- package.json | 1 + src/app.postcss | 2 + src/lib/api/client.ts | 163 +++- src/lib/api/index.ts | 2 +- src/lib/api/types.ts | 85 +- src/lib/components/BannerCropModal.svelte | 88 ++ src/lib/components/ImageCropModal.svelte | 88 ++ src/routes/(app)/+layout.svelte | 28 +- src/routes/(app)/+page.svelte | 41 +- src/routes/(app)/[stateSlug]/+page.svelte | 14 +- .../[stateSlug]/[countySlug]/+page.svelte | 323 +++++++- src/routes/(app)/admin/+page.svelte | 94 +++ src/routes/(app)/company/[id]/+page.svelte | 364 ++++++++ .../(app)/company/user/[userId]/+page.svelte | 362 ++++++++ src/routes/(app)/listing/[id]/+page.svelte | 347 ++++++++ src/routes/(app)/service/[id]/+page.svelte | 324 ++++++++ src/routes/(app)/vendor/+page.svelte | 232 +++++- src/routes/(app)/vendor/listing/+page.svelte | 57 +- .../(app)/vendor/listing/[id]/+page.svelte | 351 +++++++- src/routes/(app)/vendor/service/+page.svelte | 448 ++++++++++ .../(app)/vendor/service/[id]/+page.svelte | 780 ++++++++++++++++++ src/routes/sitemap.xml/+server.ts | 104 +++ static/robots.txt | 3 +- vite.config.ts | 2 +- 28 files changed, 4258 insertions(+), 85 deletions(-) create mode 100755 .claude/agent-memory/svelte-frontend-expert/MEMORY.md create mode 100644 src/lib/components/BannerCropModal.svelte create mode 100644 src/lib/components/ImageCropModal.svelte create mode 100644 src/routes/(app)/company/[id]/+page.svelte create mode 100644 src/routes/(app)/company/user/[userId]/+page.svelte create mode 100644 src/routes/(app)/listing/[id]/+page.svelte create mode 100644 src/routes/(app)/service/[id]/+page.svelte create mode 100644 src/routes/(app)/vendor/service/+page.svelte create mode 100644 src/routes/(app)/vendor/service/[id]/+page.svelte create mode 100644 src/routes/sitemap.xml/+server.ts diff --git a/.claude/agent-memory/svelte-frontend-expert/MEMORY.md b/.claude/agent-memory/svelte-frontend-expert/MEMORY.md new file mode 100755 index 0000000..2a0f31d --- /dev/null +++ b/.claude/agent-memory/svelte-frontend-expert/MEMORY.md @@ -0,0 +1,2 @@ +# Frontend Agent Memory +See main memory at `/mnt/code/tradewar/.claude/agent-memory/svelte-frontend-expert/MEMORY.md` diff --git a/Dockerfile b/Dockerfile index e58a0db..73e5046 100755 --- a/Dockerfile +++ b/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 \ No newline at end of file +COPY . /app + +RUN npm run build + +EXPOSE 3000 + +CMD ["node", "build"] diff --git a/Dockerfile.prod b/Dockerfile.prod index 74a3b17..80cc21c 100755 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -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 diff --git a/package-lock.json b/package-lock.json index 4c0154a..f77a6d9 100755 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index c9f948d..b8d88ef 100755 --- a/package.json +++ b/package.json @@ -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" } diff --git a/src/app.postcss b/src/app.postcss index 3943f4c..7f6a0db 100755 --- a/src/app.postcss +++ b/src/app.postcss @@ -1,4 +1,6 @@ /* Write your global styles here, in PostCSS syntax */ +@import 'cropperjs/dist/cropper.min.css'; + @tailwind base; @tailwind components; @tailwind utilities; diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index d9fefad..8a6771b 100755 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -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> { return request(`/listings/county/${countyId}`); + }, + /** + * Get a single listing by ID (public) + */ + async getById(id: number): Promise> { + return request(`/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> { + 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> { + return request(`/upload/listing/${listingId}/image`, { method: 'DELETE' }, true); + }, + + async uploadServiceListingImage(listingId: number, file: File): Promise> { + 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> { + return request(`/upload/service-listing/${listingId}/image`, { method: 'DELETE' }, true); + }, + + async uploadListingBanner(listingId: number, file: File): Promise> { + 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> { + return request(`/upload/listing/${listingId}/banner`, { method: 'DELETE' }, true); + }, + + async uploadServiceListingBanner(listingId: number, file: File): Promise> { + 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> { + return request(`/upload/service-listing/${listingId}/banner`, { method: 'DELETE' }, true); + }, +}; + +/** + * Service Listing API methods (for authenticated user's service listings) + */ +export const serviceListingApi = { + async getAll(): Promise> { + return request('/service-listing', {}, true); + }, + async getById(id: number): Promise> { + return request(`/service-listing/${id}`, {}, true); + }, + async create(data: CreateServiceListingRequest): Promise> { + return request('/service-listing', { + method: 'POST', + body: JSON.stringify(data), + }, true); + }, + async update(id: number, data: UpdateServiceListingRequest): Promise> { + return request(`/service-listing/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }, true); + }, + async delete(id: number): Promise> { + return request(`/service-listing/${id}`, { + method: 'DELETE', + }, true); + } +}; + +/** + * Public service listings API (by county) + */ +export const serviceListingsApi = { + async getByCounty(countyId: number): Promise> { + return request(`/service-listings/county/${countyId}`); + }, + async getById(id: number): Promise> { + return request(`/service-listings/${id}`); + } +}; + +/** + * Public company profile API + */ +export const companyProfileApi = { + async getById(companyId: number): Promise> { + return request(`/company/${companyId}`); + }, + async getByUserId(userId: number): Promise> { + return request(`/company/user/${userId}`); + } +}; + +/** + * Subscription API (authenticated) + */ +export const subscriptionApi = { + async get(): Promise> { + return request('/subscription', {}, true); + } +}; + +/** + * Banner API methods + */ +export const bannerApi = { + /** Public: get active banner */ + async getActive(): Promise> { + return request('/banner'); + }, + /** Admin: create/update banner */ + async create(message: string): Promise> { + return request('/admin/banner', { + method: 'POST', + body: JSON.stringify({ message }), + }, true); + }, + /** Admin: delete (deactivate) a banner */ + async delete(id: number): Promise> { + return request(`/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, diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 710342a..a3dd54f 100755 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -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'; diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index b7fa604..8a92ec5 100755 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -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; @@ -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; + +// 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; } diff --git a/src/lib/components/BannerCropModal.svelte b/src/lib/components/BannerCropModal.svelte new file mode 100644 index 0000000..52f8fc9 --- /dev/null +++ b/src/lib/components/BannerCropModal.svelte @@ -0,0 +1,88 @@ + + +{#if open} + +{/if} diff --git a/src/lib/components/ImageCropModal.svelte b/src/lib/components/ImageCropModal.svelte new file mode 100644 index 0000000..14a0d74 --- /dev/null +++ b/src/lib/components/ImageCropModal.svelte @@ -0,0 +1,88 @@ + + +{#if open} + +{/if} diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index 8e6661c..19996d8 100755 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -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 @@
+ + {#if activeBanner} +
+
+ + {activeBanner.message} +
+
+ {/if} +
-

Copyright © {new Date().getFullYear()} - All right reserved

+

Copyright © {new Date().getFullYear()} Rocket Services LLC - All rights reserved

+

🔥 1 Year Free Trial Per Account!

{#if !$user} Vendor Login {/if} diff --git a/src/routes/(app)/+page.svelte b/src/routes/(app)/+page.svelte index 43db061..2e49a8d 100755 --- a/src/routes/(app)/+page.svelte +++ b/src/routes/(app)/+page.svelte @@ -1,6 +1,7 @@ - Biz Hero - Compare Heating Oil Prices in New England - + LocalOilPrices - Heating Oil Prices & Service Companies in New England + @@ -128,13 +130,13 @@

Find the Best Heating Oil Prices - in New England + & Service Companies in New England

Compare prices from local dealers across all six states. - +

@@ -279,10 +281,10 @@ {#if valueVisible}

- Why Use Biz Hero? + Why Use LocalOilPrices?

- Helping New England homeowners make smarter heating decisions + Helping New England homeowners find the best oil prices and trusted service companies

@@ -314,9 +316,9 @@ {#if !$user}
-
+

- Are you a heating oil vendor? + Are you a heating oil or service company?

{/if} + + + + +
+ + Developer API Directory + + +
diff --git a/src/routes/(app)/[stateSlug]/+page.svelte b/src/routes/(app)/[stateSlug]/+page.svelte index e820c7b..8bef32b 100755 --- a/src/routes/(app)/[stateSlug]/+page.svelte +++ b/src/routes/(app)/[stateSlug]/+page.svelte @@ -173,10 +173,10 @@ {#if stateData} - Heating Oil Prices in {stateData.name} | Biz Hero - + Heating Oil Prices & Service Companies in {stateData.name} | LocalOilPrices + {:else} - State Not Found | Biz Hero + State Not Found | LocalOilPrices {/if} @@ -222,7 +222,9 @@

- Explore heating oil prices by county + Explore heating oil prices & local service companies by county. +
+ No heat? Find fuel dealers and emergency service providers near you. {#if statePrice}
@@ -307,7 +309,7 @@ Counties in {stateData.name}

- Select a county to view local heating oil dealers and prices + Select a county to view local heating oil dealers, prices, and service companies

{#if loading} @@ -365,7 +367,7 @@ {county.name} - View prices + View prices & services
p.price !== 0); sortMarketPrices(); // Sort immediately after fetching + // Service listings failure is non-critical + serviceListings = serviceResult.data || []; + listingsLoading = false; } @@ -226,10 +235,10 @@ {#if countyData} - Heating Oil Prices in {countyData.name}, {getStateName(countyData.state)} | Biz Hero + Heating Oil Prices in {countyData.name}, {getStateName(countyData.state)} | LocalOilPrices {:else} - County Not Found | Biz Hero + County Not Found | LocalOilPrices {/if} @@ -316,15 +325,46 @@

- Compare heating oil prices from local dealers + Compare heating oil prices and find local service companies

{/if} + + + + {#if headerVisible} +
+
+ + +
+
+ {/if} + {#if activeTab === 'fuel'} + {#if listingsLoading}
@@ -520,16 +560,25 @@ > -
- - {listing.company_name} - - {#if listing.url} - - - Visit Website + @@ -588,9 +637,13 @@ {:else} {/if} -
- - {formatOnlineOrdering(listing.online_ordering)} +
+ + {#if (listing.online_ordering === 'both' || listing.online_ordering === 'online_only') && listing.url} + Order online ! + {:else} + {formatOnlineOrdering(listing.online_ordering)} + {/if}
@@ -646,12 +699,27 @@
{#each listings.filter(l => l.phone) as listing, i}
+ {#if listing.banner_url} +
+ {listing.company_name} banner +
+ {/if}
-
-

- {listing.company_name} -

+
+
+ {#if listing.logo_url} + + {:else} + {listing.company_name.charAt(0).toUpperCase()} + {/if} +
+
@@ -707,7 +776,11 @@ @@ -930,6 +1003,210 @@ {/if} + + {:else if activeTab === 'service'} + + + + +
+
+
+

+
+ +
+ Service Companies +

+

+ Local heating oil service & boiler repair companies +

+
+ + {#if listingsLoading} +
+ {#each Array(3) as _, i} +
+ {/each} +
+ {:else if serviceListings.length === 0} +
+
+ +
+

No service companies listed yet

+

Check back soon for local service providers.

+
+ {:else} + + + + + +
+ {#each serviceListings as sl, i} +
+
+ {#if sl.banner_url} +
+ {sl.company_name} banner +
+ {/if} +
+
+ {#if sl.logo_url} + + {:else} + {sl.company_name.charAt(0).toUpperCase()} + {/if} +
+
+ {sl.company_name} + {#if sl.description} +
{sl.description}
+ {/if} +
+
+
+ {#if sl.twenty_four_hour} + 24hr + {/if} + {#if sl.emergency_service} + Emergency + {/if} + {#if sl.licensed_insured} + Licensed + {/if} +
+ +
+ {#if sl.town} +
+ + {sl.town} +
+ {/if} + {#if sl.years_experience} +
{sl.years_experience} yrs
+ {/if} +
+ +
+ {#if sl.phone} + + + {sl.phone} + + {/if} + {#if sl.website} + + + Website + + {/if} +
+
+
+ {/each} +
+ {/if} +
+
+ + {/if} + + diff --git a/src/routes/(app)/admin/+page.svelte b/src/routes/(app)/admin/+page.svelte index b021781..9a20b19 100755 --- a/src/routes/(app)/admin/+page.svelte +++ b/src/routes/(app)/admin/+page.svelte @@ -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 @@
{/if} + +
+
+

+ ⚠️ Site Banner + {#if currentBanner} + Active + {:else} + None + {/if} +

+ + {#if bannerSuccess} +
+ {bannerSuccess} +
+ {/if} + + {#if currentBanner} +
+

{currentBanner.message}

+

Created: {new Date(currentBanner.created_at || '').toLocaleString()}

+
+ + {/if} + +
+ + +
+
+
+
{#each tabs as tab}
@@ -444,6 +459,40 @@ Specify the town within the selected county
+ + +
+ +
+ { if (e.key === 'Enter') { e.preventDefault(); addTown(); } }} + placeholder="Type a town and press Add" + /> + +
+ + + {#if formData.townsServiced.length > 0} +
+ {#each formData.townsServiced as town} +
+ {town} + +
+ {/each} +
+ {/if} +
diff --git a/src/routes/(app)/vendor/listing/[id]/+page.svelte b/src/routes/(app)/vendor/listing/[id]/+page.svelte index 47b413d..c1e7a8f 100755 --- a/src/routes/(app)/vendor/listing/[id]/+page.svelte +++ b/src/routes/(app)/vendor/listing/[id]/+page.svelte @@ -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) { + 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) { + 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(); }); + + +
+ +
+
+

Company Logo

+
+ +
+ {#if currentLogoUrl} + Company logo + {:else} + + {formData.companyName ? formData.companyName.charAt(0).toUpperCase() : '?'} + + {/if} +
+ + {#if imageError} +
+ {imageError} +
+ {/if} + +
+ + {#if currentLogoUrl} + + {/if} +
+

JPEG, PNG, or WebP • Max 5MB • Displayed at 170×170px

+
+
+
+ + +
+
+

Mobile Banner Image

+
+ +
+ {#if currentBannerUrl} + Mobile banner + {:else} + + {formData.companyName ? formData.companyName.charAt(0).toUpperCase() : '?'} + + {/if} +
+ + {#if bannerError} +
+ {bannerError} +
+ {/if} + +
+ + {#if currentBannerUrl} + + {/if} +
+

JPEG, PNG, or WebP • Max 5MB • Displayed at top of mobile listings (3:1 wide format)

+
+
+
+
@@ -315,10 +576,10 @@
@@ -510,6 +771,40 @@ Specify the town within the selected county
+ + +
+ +
+ { if (e.key === 'Enter') { e.preventDefault(); addTown(); } }} + placeholder="Type a town and press Add" + /> + +
+ + + {#if formData.townsServiced.length > 0} +
+ {#each formData.townsServiced as town} +
+ {town} + +
+ {/each} +
+ {/if} +
@@ -560,6 +855,54 @@
+ +
+

Social Media & Business Links

+
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+
+
+
+ + + {#if formData.townsServiced.length > 0} +
+ {#each formData.townsServiced as town} +
+ {town} + +
+ {/each} +
+ {/if} +
+ + +
+ + + +
+
+ + + +
+

Contact Information

+
+ +
+ + + {#if errors.phone} + + {/if} +
+ + +
+ + +
+ + +
+ + +
+
+
+ + +
+ +
+ + {#if submitMessage} +
+ {submitMessage} +
+ {/if} + + + + diff --git a/src/routes/(app)/vendor/service/[id]/+page.svelte b/src/routes/(app)/vendor/service/[id]/+page.svelte new file mode 100644 index 0000000..4e44156 --- /dev/null +++ b/src/routes/(app)/vendor/service/[id]/+page.svelte @@ -0,0 +1,780 @@ + + + + + + + +
+ {#if isLoading} +
+ +
+ {:else if loadError} +
+ {loadError} +
+ Back to Dashboard + {:else} + +
+
+

Company Logo

+
+
+ {#if currentLogoUrl} + Company logo + {:else} + + {formData.companyName ? formData.companyName.charAt(0).toUpperCase() : '?'} + + {/if} +
+ + {#if imageError} +
+ {imageError} +
+ {/if} + +
+ + {#if currentLogoUrl} + + {/if} +
+

JPEG, PNG, or WebP • Max 5MB • Displayed at 170×170px

+
+
+
+ + +
+
+

Mobile Banner Image

+
+
+ {#if currentBannerUrl} + Mobile banner + {:else} + + {formData.companyName ? formData.companyName.charAt(0).toUpperCase() : '?'} + + {/if} +
+ + {#if bannerError} +
+ {bannerError} +
+ {/if} + +
+ + {#if currentBannerUrl} + + {/if} +
+

JPEG, PNG, or WebP • Max 5MB • Displayed at top of mobile listings (3:1 wide format)

+
+
+
+ +
+
+

Edit Service Listing

+ +
+ +
+ +
+ +
+ + + {#if errors.companyName} + + {/if} +
+ +
+

Service Capabilities

+
+ + + +
+
+ +
+ + + {#if errors.description} + + {/if} +
+ +
+ + +
+ +
+

Location

+
+
+ + + {#if errors.state} + + {/if} +
+ +
+ + + {#if errors.countyId} + + {/if} +
+ +
+ + + {#if errors.town} + + {/if} +
+ + +
+ +
+ { if (e.key === 'Enter') { e.preventDefault(); addTown(); } }} + placeholder="Type a town and press Add" + /> + +
+ + + {#if formData.townsServiced.length > 0} +
+ {#each formData.townsServiced as town} +
+ {town} + +
+ {/each} +
+ {/if} +
+ +
+ + +
+
+
+ +
+

Contact Information

+
+
+ + + {#if errors.phone} + + {/if} +
+ +
+ + +
+ +
+ + +
+
+
+ + +
+

Social Media & Business Links

+
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+
+ +
+ +
+ + {#if submitMessage} +
+ {submitMessage} +
+ {/if} +
+
+
+ {/if} +
diff --git a/src/routes/sitemap.xml/+server.ts b/src/routes/sitemap.xml/+server.ts new file mode 100644 index 0000000..e664c90 --- /dev/null +++ b/src/routes/sitemap.xml/+server.ts @@ -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 = ` + + ${baseUrl}/ + daily + 1.0 + + `; + + // State & County routes + ne_states.forEach((state, idx) => { + urls += ` + + ${baseUrl}/${state} + daily + 0.9 + + `; + + const counties = countiesData[idx] || []; + if (Array.isArray(counties)) { + counties.forEach((county) => { + const safeName = county.name.toLowerCase().replace(/\s+/g, '-'); + urls += ` + + ${baseUrl}/${state}/${safeName} + daily + 0.8 + + `; + }); + } + }); + + // Fuel Listings + if (Array.isArray(listings)) { + listings.forEach(listing => { + urls += ` + + ${baseUrl}/listing/${listing.id} + weekly + 0.7 + + `; + }); + } + + // Service Listings + if (Array.isArray(serviceListings)) { + serviceListings.forEach(listing => { + urls += ` + + ${baseUrl}/service/${listing.id} + monthly + 0.7 + + `; + }); + } + + const sitemap = ` + + ${urls} + + `; + + setHeaders({ + 'Content-Type': 'application/xml', + 'Cache-Control': 'max-age=0, s-maxage=3600' + }); + + return new Response(sitemap); +} diff --git a/static/robots.txt b/static/robots.txt index 6f27bb6..a14b53f 100755 --- a/static/robots.txt +++ b/static/robots.txt @@ -1,2 +1,3 @@ User-agent: * -Disallow: \ No newline at end of file +Allow: / +Sitemap: https://localoilprices.com/sitemap.xml \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index 072ec44..8d78927 100755 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ server: { proxy: { '/api': { - target: 'http://newenglandoil_api_rust:9552', + target: 'http://localoilprices_api_rust:9552', changeOrigin: true } }