From 97c17c8449cd99d5b8c3c39ed9712a2be2f23aae Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 18:34:52 +0000 Subject: [PATCH 1/2] Wire up real business data via Google Places API Add a swappable data layer so listing pages can use real Google Places listings instead of the synthetic seeded-PRNG generator: - lib/places.ts: Google Places API (New) Text Search client + mapping to the Business shape (cost-scoped field mask). - scripts/fetch-places.ts: incremental, resumable cache builder (npm run fetch:places) writing data/listings.generated.json. - data/businesses.ts: getBusinesses() returns cached real data when present, else falls back to the synthetic generator so builds never break. Places-absent fields (tagline, yearsInBusiness, website, phone, hours) are now optional. - BusinessCard + LocalBusiness JSON-LD: guard optional fields; add a Google Maps link; only emit aggregateRating when real reviews exist. - Docs, .env.example, tsx devDep + fetch:places script. --- .env.example | 6 + .gitignore | 1 + README.md | 41 ++- app/[category]/[city]/page.tsx | 4 +- components/BusinessCard.tsx | 76 +++-- data/businesses.ts | 39 ++- data/listings.generated.json | 1 + lib/places.ts | 137 +++++++++ lib/seo.ts | 22 +- package-lock.json | 492 +++++++++++++++++++++++++++++++++ package.json | 4 +- scripts/fetch-places.ts | 107 +++++++ 12 files changed, 882 insertions(+), 48 deletions(-) create mode 100644 .env.example create mode 100644 data/listings.generated.json create mode 100644 lib/places.ts create mode 100644 scripts/fetch-places.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9ab33cf --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Google Places API (New) key used by `npm run fetch:places` to populate +# data/listings.generated.json with real business listings. +# +# Create a key in Google Cloud Console, enable the "Places API (New)", and +# restrict it to that API. Billing must be enabled. +GOOGLE_PLACES_API_KEY= diff --git a/.gitignore b/.gitignore index 192904b..ca60129 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ out/ .vercel next-env.d.ts *.zip +*.tsbuildinfo diff --git a/README.md b/README.md index b24b711..73d6368 100644 --- a/README.md +++ b/README.md @@ -48,15 +48,46 @@ app/ # Next.js 15 App Router pages data/ categories.ts # 10 high-CPC categories cities.ts # 100 US cities - businesses.ts # deterministic business generator + businesses.ts # getBusinesses() — real data w/ synthetic fallback + listings.generated.json # Google Places cache (built by fetch:places) lib/ seo.ts # metadata + JSON-LD helpers site.ts # site-wide constants + places.ts # Google Places API (New) client + mapping +scripts/ + fetch-places.ts # build the listings cache from Google Places components/ # Header, Footer, BusinessCard, Breadcrumbs, JsonLd ``` -## Notes +## Business data (Google Places) -Business listings are generated deterministically from a seeded PRNG so SSG -output is stable. Replace `data/businesses.ts` with a real data source -(Google Places API, internal DB, CSV import) for production. +Pages resolve listings through `getBusinesses(category, city)` in +`data/businesses.ts`. It returns **real Google Places data** when present in +`data/listings.generated.json`, and otherwise falls back to a deterministic +seeded-PRNG generator so the site always builds — even before any data is +fetched. + +To populate real listings: + +```bash +cp .env.example .env # then set GOOGLE_PLACES_API_KEY +npm run fetch:places # fetches all 1,000 category×city pairs +``` + +The fetch script (`scripts/fetch-places.ts`) is incremental — re-running it +skips pairs already cached, so an interrupted run resumes. Useful flags: + +```bash +# Test a small slice before a full run +npm run fetch:places -- --categories=plumbers --cities=austin-tx,miami-fl +npm run fetch:places -- --delay=300 # slow down to respect rate limits +npm run fetch:places -- --fresh # ignore cache and refetch everything +``` + +Requires the **Places API (New)** enabled in Google Cloud with billing on. The +field mask requests only the fields the site renders to keep per-request cost +down. Pairs returning zero results stay uncached and fall back to synthetic +data. + +Static export (`next build`) reads the cache at build time, so the deployed +site is fully static with no runtime API calls. diff --git a/app/[category]/[city]/page.tsx b/app/[category]/[city]/page.tsx index 6a8362b..a65a3c7 100644 --- a/app/[category]/[city]/page.tsx +++ b/app/[category]/[city]/page.tsx @@ -3,7 +3,7 @@ import { notFound } from 'next/navigation'; import type { Metadata } from 'next'; import { categories, getCategoryBySlug } from '@/data/categories'; import { cities, getCityBySlug } from '@/data/cities'; -import { generateBusinesses } from '@/data/businesses'; +import { getBusinesses } from '@/data/businesses'; import { buildMetadata, itemListJsonLd, @@ -59,7 +59,7 @@ export default async function CategoryCityPage({ const city = getCityBySlug(citySlug); if (!category || !city) notFound(); - const businesses = generateBusinesses(category, city); + const businesses = getBusinesses(category, city); const top = businesses.slice(0, 10); const crumbs = [ diff --git a/components/BusinessCard.tsx b/components/BusinessCard.tsx index ebe665d..8e28b44 100644 --- a/components/BusinessCard.tsx +++ b/components/BusinessCard.tsx @@ -34,7 +34,9 @@ export default function BusinessCard({

{business.name}

-

{business.tagline}

+ {business.tagline && ( +

{business.tagline}

+ )}
@@ -45,39 +47,59 @@ export default function BusinessCard({
-
-
Phone
-
{business.phone}
-
+ {business.phone && ( +
+
Phone
+
{business.phone}
+
+ )}
Address
{business.address} {business.zip}
-
-
Hours
-
{business.hours}
-
-
-
Experience
-
{business.yearsInBusiness} years in business
-
+ {business.hours && ( +
+
Hours
+
{business.hours}
+
+ )} + {typeof business.yearsInBusiness === 'number' && ( +
+
Experience
+
{business.yearsInBusiness} years in business
+
+ )}
- - Call now - - - Visit website - + {business.phone && ( + + Call now + + )} + {business.website && ( + + Visit website + + )} + {business.googleMapsUri && ( + + View on Google + + )}
); diff --git a/data/businesses.ts b/data/businesses.ts index 9c3f1b5..b6fa3ce 100644 --- a/data/businesses.ts +++ b/data/businesses.ts @@ -1,21 +1,34 @@ import type { Category } from './categories'; import type { City } from './cities'; +import listings from './listings.generated.json'; export interface Business { id: string; name: string; rating: number; reviewCount: number; - phone: string; + phone?: string; address: string; zip: string; - yearsInBusiness: number; - website: string; - tagline: string; - hours: string; + /** Not provided by Google Places — only present for synthetic listings. */ + yearsInBusiness?: number; + website?: string; + tagline?: string; + hours?: string; verified: boolean; + /** Provenance so the UI/JSON-LD can adapt; defaults to 'synthetic'. */ + source?: 'google-places' | 'synthetic'; + placeId?: string; + googleMapsUri?: string; } +/** Real listings keyed by `${categorySlug}|${citySlug}`, populated by + * `npm run fetch:places`. Empty until the fetch script runs. */ +const realListings = listings as Record; + +export const listingKey = (categorySlug: string, citySlug: string): string => + `${categorySlug}|${citySlug}`; + // Deterministic PRNG so SSG output is stable across builds. function mulberry32(seed: number) { return function () { @@ -208,8 +221,24 @@ export function generateBusinesses(category: Category, city: City, count = 12): tagline, hours, verified: rand() < 0.55, + source: 'synthetic', }); } return list.sort((a, b) => b.rating - a.rating || b.reviewCount - a.reviewCount); } + +/** + * Resolve listings for a category + city. Returns real Google Places data + * when it has been fetched into the cache, otherwise falls back to the + * deterministic synthetic generator so builds always succeed. + */ +export function getBusinesses(category: Category, city: City, count = 12): Business[] { + const real = realListings[listingKey(category.slug, city.slug)]; + if (real && real.length > 0) { + return [...real] + .sort((a, b) => b.rating - a.rating || b.reviewCount - a.reviewCount) + .slice(0, count); + } + return generateBusinesses(category, city, count); +} diff --git a/data/listings.generated.json b/data/listings.generated.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/data/listings.generated.json @@ -0,0 +1 @@ +{} diff --git a/lib/places.ts b/lib/places.ts new file mode 100644 index 0000000..9b14cea --- /dev/null +++ b/lib/places.ts @@ -0,0 +1,137 @@ +import type { Business } from '@/data/businesses'; +import type { Category } from '@/data/categories'; +import type { City } from '@/data/cities'; + +/** + * Google Places API (New) client + mapping helpers. + * + * This module is dependency-free (plain `fetch`) so it can run both inside the + * Next.js build and from the standalone `scripts/fetch-places.ts` cache builder. + * Nothing here is imported by rendered pages directly — pages read the cached + * JSON via `getBusinesses()` in `data/businesses.ts`. + */ + +const PLACES_SEARCH_URL = 'https://places.googleapis.com/v1/places:searchText'; + +// Only request the fields we actually use — Places (New) bills by field mask. +const FIELD_MASK = [ + 'places.id', + 'places.displayName', + 'places.formattedAddress', + 'places.addressComponents', + 'places.rating', + 'places.userRatingCount', + 'places.nationalPhoneNumber', + 'places.websiteUri', + 'places.googleMapsUri', + 'places.businessStatus', + 'places.editorialSummary', + 'places.regularOpeningHours.weekdayDescriptions', +].join(','); + +interface PlacesAddressComponent { + longText?: string; + shortText?: string; + types?: string[]; +} + +interface PlaceResult { + id?: string; + displayName?: { text?: string }; + formattedAddress?: string; + addressComponents?: PlacesAddressComponent[]; + rating?: number; + userRatingCount?: number; + nationalPhoneNumber?: string; + websiteUri?: string; + googleMapsUri?: string; + businessStatus?: string; + editorialSummary?: { text?: string }; + regularOpeningHours?: { weekdayDescriptions?: string[] }; +} + +interface PlacesSearchResponse { + places?: PlaceResult[]; + nextPageToken?: string; + error?: { message?: string; status?: string }; +} + +/** The text query Google searches, e.g. "personal injury law firm in Austin, TX". */ +export function buildTextQuery(category: Category, city: City): string { + return `${category.serviceQuery} in ${city.name}, ${city.stateCode}`; +} + +function postalCodeOf(place: PlaceResult): string { + const zip = place.addressComponents?.find((c) => + c.types?.includes('postal_code'), + ); + return zip?.longText ?? zip?.shortText ?? ''; +} + +/** Map a single Google Places result into our `Business` shape. */ +export function placeToBusiness(place: PlaceResult, category: Category): Business { + const rating = typeof place.rating === 'number' ? place.rating : 0; + return { + id: place.id ?? cryptoRandomId(), + placeId: place.id, + name: place.displayName?.text ?? 'Unnamed business', + rating, + reviewCount: place.userRatingCount ?? 0, + phone: place.nationalPhoneNumber, + address: place.formattedAddress ?? '', + zip: postalCodeOf(place), + website: place.websiteUri, + googleMapsUri: place.googleMapsUri, + // Places has no "tagline"; some places carry an editorial summary. + tagline: place.editorialSummary?.text ?? `${category.singular} in your area`, + hours: place.regularOpeningHours?.weekdayDescriptions?.join(' · '), + // No "verified" concept in Places — treat operational listings as verified. + verified: place.businessStatus === 'OPERATIONAL', + source: 'google-places', + }; +} + +function cryptoRandomId(): string { + return `place-${Math.random().toString(36).slice(2, 10)}`; +} + +export interface FetchPlacesOptions { + apiKey: string; + /** Max results to request (Places returns up to 20 per page). */ + maxResults?: number; + /** ISO region bias for results. */ + regionCode?: string; +} + +/** + * Fetch and map real businesses for a category + city from Google Places. + * Throws on API/auth errors so the caller (the cache script) can surface them. + */ +export async function fetchBusinesses( + category: Category, + city: City, + opts: FetchPlacesOptions, +): Promise { + const res = await fetch(PLACES_SEARCH_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Goog-Api-Key': opts.apiKey, + 'X-Goog-FieldMask': FIELD_MASK, + }, + body: JSON.stringify({ + textQuery: buildTextQuery(category, city), + regionCode: opts.regionCode ?? 'US', + languageCode: 'en', + maxResultCount: Math.min(opts.maxResults ?? 20, 20), + }), + }); + + const data = (await res.json()) as PlacesSearchResponse; + if (!res.ok || data.error) { + const msg = data.error?.message ?? `HTTP ${res.status}`; + throw new Error(`Places API error for "${buildTextQuery(category, city)}": ${msg}`); + } + + return (data.places ?? []).map((p) => placeToBusiness(p, category)); +} diff --git a/lib/seo.ts b/lib/seo.ts index 8cdb42e..fae4822 100644 --- a/lib/seo.ts +++ b/lib/seo.ts @@ -34,14 +34,11 @@ export function buildMetadata({ title, description, path }: BuildMetadataInput): } export function localBusinessJsonLd(business: Business, category: Category, city: City) { - return { + const jsonLd: Record = { '@context': 'https://schema.org', '@type': 'LocalBusiness', '@id': `${SITE.url}/${category.slug}/${city.slug}/#${business.id}`, name: business.name, - description: business.tagline, - telephone: business.phone, - url: business.website, address: { '@type': 'PostalAddress', streetAddress: business.address.split(',')[0], @@ -55,13 +52,22 @@ export function localBusinessJsonLd(business: Business, category: Category, city latitude: city.lat, longitude: city.lng, }, - aggregateRating: { + }; + + if (business.tagline) jsonLd.description = business.tagline; + if (business.phone) jsonLd.telephone = business.phone; + if (business.website) jsonLd.url = business.website; + if (business.hours) jsonLd.openingHours = business.hours; + // Only emit aggregateRating when there are real reviews to back it. + if (business.rating > 0 && business.reviewCount > 0) { + jsonLd.aggregateRating = { '@type': 'AggregateRating', ratingValue: business.rating, reviewCount: business.reviewCount, - }, - openingHours: business.hours, - }; + }; + } + + return jsonLd; } export function itemListJsonLd( diff --git a/package-lock.json b/package-lock.json index 64a7a23..8caaa33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "autoprefixer": "10.4.20", "postcss": "8.4.49", "tailwindcss": "3.4.17", + "tsx": "4.19.2", "typescript": "5.7.2" } }, @@ -45,6 +46,414 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz", + "integrity": "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.1.tgz", + "integrity": "sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz", + "integrity": "sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.1.tgz", + "integrity": "sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz", + "integrity": "sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz", + "integrity": "sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz", + "integrity": "sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz", + "integrity": "sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz", + "integrity": "sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz", + "integrity": "sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz", + "integrity": "sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz", + "integrity": "sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz", + "integrity": "sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz", + "integrity": "sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz", + "integrity": "sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz", + "integrity": "sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz", + "integrity": "sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz", + "integrity": "sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz", + "integrity": "sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz", + "integrity": "sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz", + "integrity": "sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz", + "integrity": "sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz", + "integrity": "sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz", + "integrity": "sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@img/sharp-darwin-arm64": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", @@ -1002,6 +1411,46 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.1.tgz", + "integrity": "sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.23.1", + "@esbuild/android-arm": "0.23.1", + "@esbuild/android-arm64": "0.23.1", + "@esbuild/android-x64": "0.23.1", + "@esbuild/darwin-arm64": "0.23.1", + "@esbuild/darwin-x64": "0.23.1", + "@esbuild/freebsd-arm64": "0.23.1", + "@esbuild/freebsd-x64": "0.23.1", + "@esbuild/linux-arm": "0.23.1", + "@esbuild/linux-arm64": "0.23.1", + "@esbuild/linux-ia32": "0.23.1", + "@esbuild/linux-loong64": "0.23.1", + "@esbuild/linux-mips64el": "0.23.1", + "@esbuild/linux-ppc64": "0.23.1", + "@esbuild/linux-riscv64": "0.23.1", + "@esbuild/linux-s390x": "0.23.1", + "@esbuild/linux-x64": "0.23.1", + "@esbuild/netbsd-x64": "0.23.1", + "@esbuild/openbsd-arm64": "0.23.1", + "@esbuild/openbsd-x64": "0.23.1", + "@esbuild/sunos-x64": "0.23.1", + "@esbuild/win32-arm64": "0.23.1", + "@esbuild/win32-ia32": "0.23.1", + "@esbuild/win32-x64": "0.23.1" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1104,6 +1553,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -1666,6 +2128,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -2017,6 +2489,26 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.19.2", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.2.tgz", + "integrity": "sha512-pOUl6Vo2LUq/bSa8S5q7b91cgNSjctn9ugq/+Mvow99qW6x/UZYwzxy/3NmqoT66eHYfCVvFvACC58UBPFf28g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.23.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/typescript": { "version": "5.7.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", diff --git a/package.json b/package.json index 82d87d9..58a6aca 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "fetch:places": "tsx scripts/fetch-places.ts" }, "dependencies": { "next": "15.1.0", @@ -20,6 +21,7 @@ "autoprefixer": "10.4.20", "postcss": "8.4.49", "tailwindcss": "3.4.17", + "tsx": "4.19.2", "typescript": "5.7.2" } } diff --git a/scripts/fetch-places.ts b/scripts/fetch-places.ts new file mode 100644 index 0000000..49ca542 --- /dev/null +++ b/scripts/fetch-places.ts @@ -0,0 +1,107 @@ +/** + * Populate data/listings.generated.json with real businesses from the + * Google Places API (New). + * + * Usage: + * GOOGLE_PLACES_API_KEY=xxxx npm run fetch:places + * + * Flags (optional): + * --categories=personal-injury-lawyers,plumbers limit to these category slugs + * --cities=austin-tx,miami-fl limit to these city slugs + * --limit=20 max listings per page (<=20) + * --delay=200 ms between API calls (rate limit) + * --fresh ignore existing cache and refetch all + * + * The script is incremental by default: pairs already present in the cache are + * skipped, so an interrupted run can be resumed. Pairs that return zero results + * are left out of the cache, so the site falls back to synthetic data for them. + */ +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { categories } from '../data/categories'; +import { cities } from '../data/cities'; +import { fetchBusinesses } from '../lib/places'; +import type { Business } from '../data/businesses'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const CACHE_PATH = resolve(__dirname, '../data/listings.generated.json'); + +function arg(name: string): string | undefined { + const hit = process.argv.find((a) => a.startsWith(`--${name}=`)); + return hit?.split('=').slice(1).join('='); +} +const hasFlag = (name: string) => process.argv.includes(`--${name}`); + +function parseList(value: string | undefined): Set | null { + if (!value) return null; + return new Set(value.split(',').map((s) => s.trim()).filter(Boolean)); +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +async function main() { + const apiKey = process.env.GOOGLE_PLACES_API_KEY; + if (!apiKey) { + console.error('Missing GOOGLE_PLACES_API_KEY environment variable.'); + process.exit(1); + } + + const catFilter = parseList(arg('categories')); + const cityFilter = parseList(arg('cities')); + const maxResults = Number(arg('limit') ?? 20); + const delay = Number(arg('delay') ?? 200); + const fresh = hasFlag('fresh'); + + const cache: Record = fresh + ? {} + : JSON.parse(readFileSync(CACHE_PATH, 'utf8') || '{}'); + + const selectedCats = categories.filter((c) => !catFilter || catFilter.has(c.slug)); + const selectedCities = cities.filter((c) => !cityFilter || cityFilter.has(c.slug)); + + const total = selectedCats.length * selectedCities.length; + let done = 0; + let fetched = 0; + let failed = 0; + + console.log(`Fetching ${total} category×city pairs (delay ${delay}ms)…`); + + for (const category of selectedCats) { + for (const city of selectedCities) { + done++; + const key = `${category.slug}|${city.slug}`; + if (!fresh && cache[key]?.length) continue; // resume: already cached + + try { + const businesses = await fetchBusinesses(category, city, { apiKey, maxResults }); + if (businesses.length) { + cache[key] = businesses; + fetched++; + } + if (done % 25 === 0 || done === total) { + // Persist periodically so progress survives interruption. + writeCache(cache); + console.log(` [${done}/${total}] ${key} → ${businesses.length} listings`); + } + } catch (err) { + failed++; + console.warn(` ✗ ${key}: ${(err as Error).message}`); + } + await sleep(delay); + } + } + + writeCache(cache); + console.log(`Done. ${fetched} pairs fetched, ${failed} failed, ${Object.keys(cache).length} total in cache.`); +} + +function writeCache(cache: Record) { + mkdirSync(dirname(CACHE_PATH), { recursive: true }); + writeFileSync(CACHE_PATH, `${JSON.stringify(cache, null, 2)}\n`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); From be376088c96f1fe07005e7646d4e5f5cc9c5e399 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 18:37:37 +0000 Subject: [PATCH 2/2] Rebrand from PrimeDirectory to Grow with BA Update the site identity in lib/site.ts (name, url, email, twitter, description) and replace hardcoded 'PrimeDirectory' copy in the about, contact, privacy, terms, and home pages. Header, Footer, and layout metadata already derive from SITE so they update automatically. Also rename the npm package to grow-with-ba. --- README.md | 2 +- app/about/page.tsx | 8 ++++---- app/contact/page.tsx | 4 ++-- app/page.tsx | 2 +- app/privacy/page.tsx | 4 ++-- app/terms/page.tsx | 6 +++--- lib/site.ts | 10 +++++----- package-lock.json | 4 ++-- package.json | 2 +- 9 files changed, 21 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 73d6368..1c5b5fb 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# PrimeDirectory +# Grow with BA A programmatic-SEO directory site built with Next.js 15 (App Router) that generates **1,000+ statically rendered pages** targeting high-CPC service diff --git a/app/about/page.tsx b/app/about/page.tsx index 99e7d03..2f91db4 100644 --- a/app/about/page.tsx +++ b/app/about/page.tsx @@ -3,9 +3,9 @@ import { buildMetadata } from '@/lib/seo'; import Breadcrumbs from '@/components/Breadcrumbs'; export const metadata: Metadata = buildMetadata({ - title: 'About PrimeDirectory', + title: 'About Grow with BA', description: - 'PrimeDirectory connects homeowners and small businesses with vetted, top-rated local pros across the categories that matter most.', + 'Grow with BA connects homeowners and small businesses with vetted, top-rated local pros across the categories that matter most.', path: '/about/', }); @@ -18,9 +18,9 @@ export default function AboutPage() {
-

About PrimeDirectory

+

About Grow with BA

- We built PrimeDirectory because finding a trustworthy local professional in + We built Grow with BA because finding a trustworthy local professional in high-stakes categories — legal, financial, medical, and home services — is one of the highest-friction tasks on the internet.

diff --git a/app/contact/page.tsx b/app/contact/page.tsx index c92a4b0..39d21ea 100644 --- a/app/contact/page.tsx +++ b/app/contact/page.tsx @@ -5,7 +5,7 @@ import { SITE } from '@/lib/site'; export const metadata: Metadata = buildMetadata({ title: 'Contact Us', - description: 'Get in touch with PrimeDirectory or list your business.', + description: 'Get in touch with Grow with BA or list your business.', path: '/contact/', }); @@ -18,7 +18,7 @@ export default function ContactPage() {
-

Contact PrimeDirectory

+

Contact Grow with BA

Questions, feedback, or want to list your business? We typically reply within one business day. diff --git a/app/page.tsx b/app/page.tsx index f193940..6ac1dd9 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -120,7 +120,7 @@ export default function HomePage() {

-

Why PrimeDirectory

+

Why Grow with BA

10
diff --git a/app/privacy/page.tsx b/app/privacy/page.tsx index a608694..eabe611 100644 --- a/app/privacy/page.tsx +++ b/app/privacy/page.tsx @@ -4,7 +4,7 @@ import Breadcrumbs from '@/components/Breadcrumbs'; export const metadata: Metadata = buildMetadata({ title: 'Privacy Policy', - description: 'PrimeDirectory privacy policy.', + description: 'Grow with BA privacy policy.', path: '/privacy/', }); @@ -20,7 +20,7 @@ export default function PrivacyPage() {

Privacy Policy

Last updated: April 2026

- PrimeDirectory respects your privacy. We collect only the information needed + Grow with BA respects your privacy. We collect only the information needed to operate the directory and improve recommendations. We do not sell personal data to third parties.

diff --git a/app/terms/page.tsx b/app/terms/page.tsx index 558062b..9083231 100644 --- a/app/terms/page.tsx +++ b/app/terms/page.tsx @@ -4,7 +4,7 @@ import Breadcrumbs from '@/components/Breadcrumbs'; export const metadata: Metadata = buildMetadata({ title: 'Terms of Service', - description: 'PrimeDirectory terms of service.', + description: 'Grow with BA terms of service.', path: '/terms/', }); @@ -20,7 +20,7 @@ export default function TermsPage() {

Terms of Service

Last updated: April 2026

- By using PrimeDirectory you agree to these terms. Listings are provided for + By using Grow with BA you agree to these terms. Listings are provided for informational purposes only. Always verify credentials, license status, and pricing directly with the listed business.

@@ -31,7 +31,7 @@ export default function TermsPage() {

Liability

- PrimeDirectory is provided "as is" without warranties of any kind. We are + Grow with BA is provided "as is" without warranties of any kind. We are not liable for transactions or interactions between users and listed businesses.

diff --git a/lib/site.ts b/lib/site.ts index dbb1db7..9c761c0 100644 --- a/lib/site.ts +++ b/lib/site.ts @@ -1,10 +1,10 @@ export const SITE = { - name: 'PrimeDirectory', + name: 'Grow with BA', tagline: 'Find the best local pros in 100 cities', - url: 'https://primedirectory.example.com', + url: 'https://growwithba.com', description: - 'PrimeDirectory connects homeowners and small businesses with vetted, top-rated local professionals across 10 high-stakes service categories in 100 US cities.', - email: 'hello@primedirectory.example.com', + 'Grow with BA connects homeowners and small businesses with vetted, top-rated local professionals across 10 high-stakes service categories in 100 US cities.', + email: 'hello@growwithba.com', phone: '(800) 555-0142', - twitter: '@primedirectory', + twitter: '@growwithba', }; diff --git a/package-lock.json b/package-lock.json index 8caaa33..1164ddb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "prime-directory", + "name": "grow-with-ba", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "prime-directory", + "name": "grow-with-ba", "version": "1.0.0", "dependencies": { "next": "15.1.0", diff --git a/package.json b/package.json index 58a6aca..80ed0fd 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "prime-directory", + "name": "grow-with-ba", "version": "1.0.0", "private": true, "scripts": {