Skip to content
6 changes: 6 additions & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@
gtag('js', new Date());
gtag('config', 'G-JG137ZJXD7', { send_page_view: false });
</script>

<!-- Google AdSense. Verifies site ownership (the "AdSense code snippet"
method) and, once approved, loads the ad library. prerender.mjs uses
the built dist/index.html as the template for all 1494 suburb pages, so
this tag propagates to every page automatically — no per-page work. -->
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-5982385876517812" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
Expand Down
1 change: 1 addition & 0 deletions frontend/public/ads.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
google.com, pub-5982385876517812, DIRECT, f08c47fec0942fa0
141 changes: 121 additions & 20 deletions frontend/scripts/prerender.mjs
Original file line number Diff line number Diff line change
@@ -1,41 +1,100 @@
// Per-suburb <head> prerender + sitemap generator.
// Per-suburb prerender (head + body) + sitemap generator.
//
// WHY: SuburbLens is a SPA — every URL serves the same dist/index.html and the
// real content is drawn by JS. Social crawlers (WeChat / WhatsApp / Slack /
// Facebook) do NOT run JS, so every shared suburb link previews with the same
// generic OG card. Fix: emit one tiny HTML file per suburb whose <head> carries
// that suburb's <title> / og:title / og:description. <body> stays the empty
// <div id="root"></div> — the SPA hydrates normally for real users.
// real content is drawn by JS. Two audiences never run that JS:
// 1. Social crawlers (WeChat / WhatsApp / Slack / Facebook) — need per-suburb
// <head> tags or every shared link previews with the same generic OG card.
// 2. The AdSense reviewer viewing "page source", and any non-JS crawler — see
// only an empty <div id="root">, which reads as Low value content.
//
// Runs AFTER `vite build`, over the freshly built dist/. Zero dependencies.
// Census data updates every ~5 years, so regenerating only at build time is fine.
// Fix: emit one HTML file per suburb whose <head> carries that suburb's
// title/OG tags AND whose <div id="root"> is seeded with a real, data-driven
// narrative paragraph. main.tsx mounts with createRoot(), which REPLACES the
// contents of #root on load — so real users get the full SPA and the seeded
// prose is only ever seen before hydration / by crawlers. No duplicate content.
//
// See docs/planning/seo-and-sharing.md, Stage 1.
// The narrative text is produced by src/lib/narrative.ts — the SAME pure
// function the <SuburbNarrative> component uses — transpiled on the fly here so
// there is a single source of truth (adsense-plan §8.5.1 / §8.5.6 step 7). We
// transpile via the already-installed `typescript` dep rather than relying on
// the build container's Node version to strip types.
//
// Runs AFTER `vite build`, over the freshly built dist/. Census data updates
// every ~5 years, so regenerating only at build time is fine.
//
// See docs/planning/seo-and-sharing.md, Stage 1; docs/planning/adsense-plan.md §8.5.

import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
import { readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs'
import { resolve } from 'node:path'
import { tmpdir } from 'node:os'
import { pathToFileURL } from 'node:url'
import ts from 'typescript'

// Same env var Vite bakes into the bundle, so this hits the exact API the app
// uses. In Amplify's build container it is already set. Locally, pass it:
// VITE_API_BASE_URL=https://s5120jvyf4.execute-api.ap-southeast-2.amazonaws.com npm run prerender
const API = process.env.VITE_API_BASE_URL
const SITE = process.env.SITE_URL ?? 'https://www.suburblensapp.com'
const DIST = resolve('dist')
const NARRATIVE_CONCURRENCY = 10

if (!API) {
console.error('[prerender] VITE_API_BASE_URL is not set — cannot fetch the suburb list. Aborting.')
process.exit(1)
}

// Escape text before it goes into an HTML attribute, or names like "O'Connor"
// and "St Mary's" would break the tag.
// Escape text before it goes into an HTML *attribute* (title, og:*, canonical).
const esc = (s) =>
String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')

// Escape text that becomes HTML *body* content (the narrative paragraphs).
const escHtml = (s) =>
String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')

// Simple promise pool: run `fn` over `items` at most `limit` at a time, keeping
// the results aligned to the input order. Avoids a dependency (p-limit) and
// stops 1494 fetches from hammering the API all at once.
async function mapLimit(items, limit, fn) {
const results = new Array(items.length)
let cursor = 0
async function worker() {
while (cursor < items.length) {
const idx = cursor++
results[idx] = await fn(items[idx], idx)
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
return results
}

// --- Load the shared narrative function -------------------------------------
// Transpile src/lib/narrative.ts to a temp .mjs and import it. The file only
// imports TYPES from ../types/api, which transpileModule elides, so the output
// is self-contained (no runtime imports to resolve).
const narrativeTsPath = resolve('src', 'lib', 'narrative.ts')
const transpiled = ts.transpileModule(readFileSync(narrativeTsPath, 'utf8'), {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
}).outputText
const tmpNarrativePath = resolve(tmpdir(), `suburblens-narrative.${process.pid}.mjs`)
writeFileSync(tmpNarrativePath, transpiled)
let buildNarrative
try {
;({ buildNarrative } = await import(pathToFileURL(tmpNarrativePath).href))
} finally {
try {
unlinkSync(tmpNarrativePath)
} catch {
// best-effort cleanup; a stray temp file is harmless
}
}

// 1. Pull every suburb. /api/suburbs/heatmap already returns the full Sydney +
// Melbourne set as a GeoJSON FeatureCollection — no new backend endpoint.
console.log(`[prerender] fetching suburb list from ${API}/api/suburbs/heatmap`)
Expand All @@ -54,25 +113,65 @@ if (suburbs.length === 0) {
process.exit(1)
}

// 2. Use the built index.html as the template.
// 2. For each suburb, fetch its tenure — the single richest call, enough to
// write a substantial opening paragraph. Concurrency-limited; a failed
// suburb degrades to a head-only page rather than aborting the whole build.
console.log(`[prerender] fetching tenure for ${suburbs.length} suburbs (concurrency ${NARRATIVE_CONCURRENCY})`)
const tenures = await mapLimit(suburbs, NARRATIVE_CONCURRENCY, async ({ salCode }) => {
try {
const r = await fetch(`${API}/api/suburbs/${salCode}/tenure`)
if (!r.ok) return null
return await r.json()
} catch {
return null
}
})

// 3. Use the built index.html as the template.
const template = readFileSync(resolve(DIST, 'index.html'), 'utf8')

// 3. For each suburb, swap only the head tags; everything else stays identical.
// Build the static narrative block seeded into #root. Kept minimal — React wipes
// it on mount, so its only jobs are to be readable in raw source and to not look
// broken during the pre-hydration instant.
function narrativeBlock(tenure) {
if (!tenure) return ''
const { paragraphs, source } = buildNarrative({ tenure })
if (!paragraphs.length) return ''
const body = paragraphs.map((p) => `<p style="margin:0 0 .75rem">${escHtml(p)}</p>`).join('')
return (
`<section data-prerendered-narrative style="max-width:48rem;margin:0 auto;padding:2rem 1.25rem;` +
`font-family:system-ui,-apple-system,sans-serif;color:#9aa0ad;line-height:1.65;background:#0d0f14">` +
`<h1 style="color:#eef1f6;font-size:1.5rem;margin:0 0 1rem">${escHtml(tenure.salName)}</h1>` +
body +
`<p style="font-size:.75rem;color:#5b606d;margin-top:1rem">${escHtml(source)}</p>` +
`</section>`
)
}

// 4. For each suburb, swap the head tags and seed the body.
const urls = []
for (const { salCode, salName, stateName } of suburbs) {
let seeded = 0
suburbs.forEach(({ salCode, salName, stateName }, i) => {
const where = stateName ? `${salName}, ${stateName}` : salName
const title = `${salName} — tenure & Census data | SuburbLens`
const desc = `${where}: ABS Census tenure, community language, country of origin and education data for this suburb.`
const url = `${SITE}/suburb/${salCode}`

const html = template
let html = template
.replace(/<title>.*?<\/title>/, `<title>${esc(title)}</title>`)
.replace(/(<meta name="description" content=").*?(")/, `$1${esc(desc)}$2`)
.replace(/(<meta property="og:title" content=").*?(")/, `$1${esc(title)}$2`)
.replace(/(<meta property="og:description" content=").*?(")/, `$1${esc(desc)}$2`)
.replace(/(<meta property="og:url" content=").*?(")/, `$1${esc(url)}$2`)
.replace(/(<link rel="canonical" href=").*?(")/, `$1${esc(url)}$2`)

const block = narrativeBlock(tenures[i])
if (block) {
const before = html
html = html.replace(/<div id="root">\s*<\/div>/, `<div id="root">${block}</div>`)
if (html !== before) seeded++
}

// Flat file per suburb, e.g. dist/suburb/12345.html — NOT a subdir index.html.
// Amplify's rewrite wildcard <*> cannot be followed by "/", so the rule that
// serves these (/suburb/<*> -> /suburb/<*>.html) needs a file extension, not a
Expand All @@ -81,13 +180,13 @@ for (const { salCode, salName, stateName } of suburbs) {
mkdirSync(dir, { recursive: true })
writeFileSync(resolve(dir, `${String(salCode)}.html`), html)
urls.push(url)
}
})

// 4. Sitemap for Google Search Console. Static routes first, then every suburb.
// 5. Sitemap for Google Search Console. Static routes first, then every suburb.
// This overwrites the hand-written public/sitemap.xml, so the static routes
// have to be repeated here or they silently drop out of the deployed sitemap.
// /login is omitted on purpose — robots.txt disallows it.
const STATIC_ROUTES = ['/', '/map', '/privacy', '/about']
const STATIC_ROUTES = ['/', '/map', '/privacy', '/about', '/methodology', '/suburbs']
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${STATIC_ROUTES.map((p) => ` <url><loc>${SITE}${p === '/' ? '/' : p}</loc></url>`).join('\n')}
Expand All @@ -96,4 +195,6 @@ ${urls.map((u) => ` <url><loc>${u}</loc></url>`).join('\n')}
`
writeFileSync(resolve(DIST, 'sitemap.xml'), sitemap)

console.log(`[prerender] wrote ${urls.length} suburb pages + sitemap.xml into dist/`)
console.log(
`[prerender] wrote ${urls.length} suburb pages (${seeded} with a seeded narrative) + sitemap.xml into dist/`,
)
4 changes: 4 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import MapPage from './pages/MapPage'
import LoginPage from './pages/LoginPage'
import PrivacyPage from './pages/PrivacyPage'
import AboutPage from './pages/AboutPage'
import MethodologyPage from './pages/MethodologyPage'
import BrowsePage from './pages/BrowsePage'
import AuthBadge from './components/AuthBadge'
import { trackPageView } from './lib/analytics'

Expand Down Expand Up @@ -40,13 +42,15 @@ export default function App() {
Signing in only buys the AI assistant. */}
<Route path="/" element={<HomePage />} />
<Route path="/suburb/:salCode" element={<SuburbDetailPage />} />
<Route path="/suburbs" element={<BrowsePage />} />
<Route path="/compare" element={<ComparePage />} />
<Route path="/map" element={<MapPage />} />

{/* Must stay reachable signed-out: the Chrome Web Store listing links
here, and reviewers open it without an account. */}
<Route path="/privacy" element={<PrivacyPage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/methodology" element={<MethodologyPage />} />
</Routes>
</BrowserRouter>
</QueryClientProvider>
Expand Down
29 changes: 29 additions & 0 deletions frontend/src/api/suburbs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,35 @@ export function useSuburbTenureBatch(salCodes: string[]) {
})
}

// —— 全量 suburb 列表(用于 /suburbs 索引页)——
// 复用 /api/suburbs/heatmap:不带 city 参数即返回悉尼+墨尔本全部,且已在 Redis
// + 浏览器 Cache-Control 里缓存。这里只取 feature 的 properties(名字/城市),
// 丢掉几何,交给索引页按城市 + 首字母 A–Z 分组渲染成内链。
export interface SuburbListEntry {
salCode: string
salName: string
gccsaCode: string
stateName: string
}

export function useAllSuburbs() {
return useQuery<SuburbListEntry[]>({
queryKey: ['all-suburbs'],
queryFn: async () => {
const res = await fetch(`${API_BASE}/api/suburbs/heatmap`)
if (!res.ok) throw new Error('Failed to load the suburb list.')
const geojson = await res.json() as { features?: Array<{ properties?: Record<string, unknown> }> }
return (geojson.features ?? []).map(f => ({
salCode: String(f.properties?.salCode ?? ''),
salName: String(f.properties?.salName ?? ''),
gccsaCode: String(f.properties?.gccsaCode ?? ''),
stateName: String(f.properties?.stateName ?? ''),
}))
},
staleTime: 30 * 60 * 1000, // 静态数据,缓存久一点
})
}

// —— 热门 suburb 计数(自建,写入 Supabase suburb_views)——
// GA4 的数据取不回前端,首页要读的"最近 30 天最热"只能存在自己库里。
// 写入不走 C# 后端(Dapper is query-only),前端直接打 Supabase,受 RLS 约束:
Expand Down
6 changes: 0 additions & 6 deletions frontend/src/components/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,6 @@ const Dot = () => (
// on any page can reach /about and /privacy from here, and every suburb page
// linking to them is a strong internal-link signal for Search too.
//
// /methodology and /suburbs are commented out on purpose — those routes do not
// exist yet, and React Router has no catch-all, so a link to them would open a
// blank page (a listed AdSense rejection reason). Un-comment each line the day
// its page ships (adsense-plan.md §8.5.3 / §8.5.4).
export default function Footer({ className = '' }: { className?: string }) {
return (
<footer className={`font-mono text-xs text-dim ${className}`}>
Expand All @@ -28,7 +24,6 @@ export default function Footer({ className = '' }: { className?: string }) {
About
</Link>
<Dot />
{/*
<Link to="/methodology" className="transition-colors hover:text-lemon">
Methodology
</Link>
Expand All @@ -37,7 +32,6 @@ export default function Footer({ className = '' }: { className?: string }) {
All suburbs
</Link>
<Dot />
*/}
<Link to="/privacy" className="transition-colors hover:text-lemon">
Privacy
</Link>
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/components/SuburbCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import EducationChart from './EducationChart'
import CrimeChart from './CrimeChart'
import LoadingSkeleton from './LoadingSkeleton'
import NearbySuburbs from './NearbySuburbs'
import SuburbNarrative from './SuburbNarrative'

interface Props {
salCode: string
Expand Down Expand Up @@ -220,6 +221,8 @@ export default function SuburbCard({ salCode, onAdd, onRemove, defaultNearbyExpa
trendLabel={data.trendLabel}
/>

<SuburbNarrative salCode={salCode} />

<NearbySuburbs
salCode={salCode}
defaultExpanded={defaultNearbyExpanded}
Expand Down
48 changes: 48 additions & 0 deletions frontend/src/components/SuburbNarrative.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {
useSuburbTenure,
useSuburbLanguage,
useSuburbBirthCountry,
useSuburbEducation,
useSuburbCrime,
} from '../api/suburbs'
import { buildNarrative } from '../lib/narrative'

// An editorial, data-driven paragraph shown on every suburb card. It reuses the
// exact hooks (and therefore the TanStack cache) that SuburbCard's other
// sections already populate, so it fires no extra network requests. Its job is
// to turn "charts + numbers" into readable prose — the strongest evidence for
// AdSense that these are genuine content pages, not scaled/templated filler
// (adsense-plan §8.5.1).
export default function SuburbNarrative({ salCode }: { salCode: string }) {
const tenure = useSuburbTenure(salCode)
const language = useSuburbLanguage(salCode)
const birthCountry = useSuburbBirthCountry(salCode)
const education = useSuburbEducation(salCode)
const crime = useSuburbCrime(salCode)

// Tenure is the backbone; without it there is nothing to say. The other
// dimensions fill in the prose as their queries settle.
if (!tenure.data) return null

const { paragraphs, source } = buildNarrative({
tenure: tenure.data,
language: language.data,
birthCountry: birthCountry.data,
education: education.data,
crime: crime.data,
})

if (!paragraphs.length) return null

return (
<section className="bg-surface border border-white/[0.07] shadow-xl shadow-black/30 rounded-2xl p-6">
<div className="font-mono text-[11px] uppercase tracking-wider text-lemon mb-3">In brief</div>
<div className="space-y-3 text-sm leading-relaxed text-muted">
{paragraphs.map((p, i) => (
<p key={i}>{p}</p>
))}
</div>
<p className="mt-4 font-mono text-[11px] text-dim">{source}</p>
</section>
)
}
Loading
Loading