Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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=
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ out/
.vercel
next-env.d.ts
*.zip
*.tsbuildinfo
43 changes: 37 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
4 changes: 2 additions & 2 deletions app/[category]/[city]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = [
Expand Down
8 changes: 4 additions & 4 deletions app/about/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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/',
});

Expand All @@ -18,9 +18,9 @@ export default function AboutPage() {
</div>
</div>
<section className="mx-auto max-w-3xl px-4 py-12 prose-clean">
<h1 className="text-4xl font-bold text-slate-900">About PrimeDirectory</h1>
<h1 className="text-4xl font-bold text-slate-900">About Grow with BA</h1>
<p className="mt-6 text-lg text-slate-600">
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.
</p>
Expand Down
4 changes: 2 additions & 2 deletions app/contact/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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/',
});

Expand All @@ -18,7 +18,7 @@ export default function ContactPage() {
</div>
</div>
<section className="mx-auto max-w-3xl px-4 py-12">
<h1 className="text-4xl font-bold text-slate-900">Contact PrimeDirectory</h1>
<h1 className="text-4xl font-bold text-slate-900">Contact Grow with BA</h1>
<p className="mt-4 text-lg text-slate-600">
Questions, feedback, or want to list your business? We typically reply
within one business day.
Expand Down
2 changes: 1 addition & 1 deletion app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ export default function HomePage() {
</section>

<section className="mx-auto max-w-6xl px-4 py-16">
<h2 className="text-3xl font-bold text-slate-900">Why PrimeDirectory</h2>
<h2 className="text-3xl font-bold text-slate-900">Why Grow with BA</h2>
<div className="mt-8 grid gap-6 md:grid-cols-3">
<div className="rounded-xl border border-slate-200 p-6">
<div className="text-2xl font-bold text-brand-700">10</div>
Expand Down
4 changes: 2 additions & 2 deletions app/privacy/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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/',
});

Expand All @@ -20,7 +20,7 @@ export default function PrivacyPage() {
<h1 className="text-4xl font-bold text-slate-900">Privacy Policy</h1>
<p className="mt-4 text-slate-600">Last updated: April 2026</p>
<p className="mt-4 text-slate-600">
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.
</p>
Expand Down
6 changes: 3 additions & 3 deletions app/terms/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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/',
});

Expand All @@ -20,7 +20,7 @@ export default function TermsPage() {
<h1 className="text-4xl font-bold text-slate-900">Terms of Service</h1>
<p className="mt-4 text-slate-600">Last updated: April 2026</p>
<p className="mt-4 text-slate-600">
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.
</p>
Expand All @@ -31,7 +31,7 @@ export default function TermsPage() {
</p>
<h2 className="mt-8 text-xl font-bold text-slate-900">Liability</h2>
<p className="mt-3 text-slate-600">
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.
</p>
Expand Down
76 changes: 49 additions & 27 deletions components/BusinessCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ export default function BusinessCard({
<h3 className="mt-2 text-lg font-semibold text-slate-900">
{business.name}
</h3>
<p className="text-sm text-slate-600">{business.tagline}</p>
{business.tagline && (
<p className="text-sm text-slate-600">{business.tagline}</p>
)}
</div>
<div className="text-right text-sm">
<Stars rating={business.rating} />
Expand All @@ -45,39 +47,59 @@ export default function BusinessCard({
</div>

<dl className="mt-4 grid gap-2 text-sm text-slate-600 sm:grid-cols-2">
<div>
<dt className="text-xs uppercase text-slate-400">Phone</dt>
<dd className="font-medium text-slate-900">{business.phone}</dd>
</div>
{business.phone && (
<div>
<dt className="text-xs uppercase text-slate-400">Phone</dt>
<dd className="font-medium text-slate-900">{business.phone}</dd>
</div>
)}
<div>
<dt className="text-xs uppercase text-slate-400">Address</dt>
<dd>{business.address} {business.zip}</dd>
</div>
<div>
<dt className="text-xs uppercase text-slate-400">Hours</dt>
<dd>{business.hours}</dd>
</div>
<div>
<dt className="text-xs uppercase text-slate-400">Experience</dt>
<dd>{business.yearsInBusiness} years in business</dd>
</div>
{business.hours && (
<div>
<dt className="text-xs uppercase text-slate-400">Hours</dt>
<dd>{business.hours}</dd>
</div>
)}
{typeof business.yearsInBusiness === 'number' && (
<div>
<dt className="text-xs uppercase text-slate-400">Experience</dt>
<dd>{business.yearsInBusiness} years in business</dd>
</div>
)}
</dl>

<div className="mt-4 flex flex-wrap gap-2">
<a
href={`tel:${business.phone.replace(/\D/g, '')}`}
className="rounded-md bg-brand-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-brand-700"
>
Call now
</a>
<a
href={business.website}
rel="nofollow noopener noreferrer"
target="_blank"
className="rounded-md border border-slate-200 px-3 py-1.5 text-sm font-semibold text-slate-700 hover:bg-slate-50"
>
Visit website
</a>
{business.phone && (
<a
href={`tel:${business.phone.replace(/\D/g, '')}`}
className="rounded-md bg-brand-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-brand-700"
>
Call now
</a>
)}
{business.website && (
<a
href={business.website}
rel="nofollow noopener noreferrer"
target="_blank"
className="rounded-md border border-slate-200 px-3 py-1.5 text-sm font-semibold text-slate-700 hover:bg-slate-50"
>
Visit website
</a>
)}
{business.googleMapsUri && (
<a
href={business.googleMapsUri}
rel="nofollow noopener noreferrer"
target="_blank"
className="rounded-md border border-slate-200 px-3 py-1.5 text-sm font-semibold text-slate-700 hover:bg-slate-50"
>
View on Google
</a>
)}
</div>
</article>
);
Expand Down
39 changes: 34 additions & 5 deletions data/businesses.ts
Original file line number Diff line number Diff line change
@@ -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<string, Business[]>;

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 () {
Expand Down Expand Up @@ -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);
}
1 change: 1 addition & 0 deletions data/listings.generated.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
Loading